# Changelog

## 0.87.1

### Unanswered approvals hand off once and fail closed

The installed agent instructions now distinguish a live timeout from cancelled,
missing and unavailable questions. They poll once, cancel before moving the
decision into the current client, reconcile the cancellation race once, and stop
when Pushary cannot safely fence the question state.

## 0.86.0

### Approving a plan from your phone

Exiting plan mode blocked the terminal and pushed nothing, so an away user's agent
sat waiting on the one approval that decides everything after it. `ExitPlanMode` was
never in the hook's tool matcher, and `plan` is a mode Pushary steps aside for
entirely, so matching it alone would not have helped either.

The plan approval now reaches your phone before Pushary steps aside, and it carries
the plan itself, redacted and capped like any diff, so you are approving something
you can read rather than a bare tool name. Two options rather than the terminal's
three: a hook cannot set the permission mode the session lands in, so offering that
choice would have quietly dropped half of what you picked.

Falls back to the terminal exactly as before whenever the phone cannot answer.

## 0.85.2

### One agent-protocol renderer for CLI and native approvals

Claude Code, Codex and Gemini hook output now comes from the shared contracts
renderer also used by the native Mac gate. This keeps the CLI wire bytes unchanged
while preventing the native bridge and published hooks from drifting into different
allow or deny shapes.

## 0.85.1

### A rule you wrote is now honoured in auto mode

A policy set to deny a tool outright was computed and then discarded in the three
Claude Code permission modes Pushary steps aside for: `auto`, `plan` and `dontAsk`.
`auto` is the default on Pro, Max and Team, so this was most sessions. The native
classifier has never heard of your rule, so it would approve what you had forbidden.

The defer exists to stop Pushary double-prompting on top of Claude Code's own
prompt. A deny prompts nobody, so that reasoning never covered it. A standing deny
now sits with the kill switch, ahead of the defer, and applies in every mode.

Approvals are unchanged: `auto` still defers them, and a tool the classifier
decides to ask about is still reclaimed through `PermissionRequest` and pushed to
your phone. This only closes the case where a rule of yours was silently dropped.

The Codex, Gemini and SDK wrapper paths already behaved this way. This brings the
Claude Code path in line with them.

## 0.85.0

### A hook command that cannot outlive what it points at

Every hook command written into `~/.claude/settings.json`, `~/.codex/hooks.json`
and `~/.gemini/settings.json`, the free bell included, is now wrapped so that a missing binary, a removed
`node`, a changed npm prefix or a moved home directory exits 0 and the agent reads
"no opinion". Before, any of those turned every tool call into a failing hook.
The guard is tested on every shell that can be `/bin/sh`, including dash, and with
`node` off PATH, which used to exit 127 and print on every tool call.

Codex hashes the exact command string to trust a hook, so `pushary upgrade` rewrites
the trust entry when it re-applies the hooks.

### Hooks registered for the CLI you have

Claude Code events are registered from a table with a version floor per event.
`SubagentStart`, `SubagentStop`, `PreCompact`, `PostCompact` and `TeammateIdle`
are added on a CLI that understands them, and `pushary upgrade` re-registers
against the CLI you have now. Disconnect removes every event in the table; a
hand-typed list used to leave the five observability events behind.

### `pushary disconnect <agent>`

Removes the MCP server, the hooks, the skill and, for Codex, the trust entries and
the API key from `config.toml`, for one agent, leaving the others alone. The bin
map entry was written in a shape the build silently dropped, and the command read
the dispatcher's own token as the agent name; both are fixed before this, its
first release, and a test now holds every bin entry to the one shape tsup builds.

### A kill switch per hook path

`~/.pushary/admission-rules.json` (`{"version":1,"disabled":["claude:PostToolUse"]}`)
silences one event on one agent, or one agent entirely, without a release. Every
hook binary reads its input through this check, from the same file the macOS
helper reads, so one file governs both transports. Deny-only: nothing in the file
can enable a hook.

### `PostToolUse` no longer pays two round trips per tool call

The telemetry hooks reuse the mode state the gate fetched for the same session
within the last 60 seconds. The gate itself still reads it live, so a remote stop
and a scope contract are never served stale where they decide anything.

## 0.84.1

### Remote starts survive lost acknowledgements without double-launching

A phone-started agent now claims exclusive process ownership before the provider
starts. If the daemon restarts or the launch acknowledgement is lost, retrying the
same request reconnects to the existing process instead of starting a second one.

### Repository approval scopes reach Codex and Gemini

Codex and Gemini sessions now carry their repository identity through approval
requests, so repository-scoped rules stay inside the repository that created them.

### Encrypted transcripts retry safely

Transcript writers now retry while a session key is temporarily unavailable and
stop on a real key conflict, preventing undecryptable transcript entries from being
accepted or silently dropped.

## 0.84.0

### The phone can start an agent on a machine that is not running one

The phone could reach a session that already existed. Starting one meant being at
the keyboard. `pushary setup` and `pushary claude` now bring up a small background
process, `pushary-daemon`, which watches for a launch requested from the phone and
starts a real session with the prompt you sent.

It is a poller over a durable queue, not an open socket. Idle, it asks the server
for work every 30 seconds and sends one heartbeat a minute, and it carries a
prompt, never any code. One request is in flight at a time, each bounded by its
own timeout, with a spawn rate cap so a queue backlog cannot fill a machine with
agents. Exactly one daemon runs per machine: a second one asks the first to hand
over rather than racing it, and it declines to take over from a daemon that is
still healthy.

This is a background process that was not running before. `pushary doctor` reports
whether it is up, and `pushary daemon` in a terminal shows you what it is doing.

A launch is now acknowledged rather than assumed. The daemon claims a request,
confirms the session it started, and the phone shows what actually happened,
including the failure when a launch does not come up.

### Always allowing a tool no longer has to mean everywhere

"Approve and always allow" wrote one kind of rule: this tool, this whole
workspace, until you delete it. That is the right scope for `Read` and far too
wide for a push.

The approval now offers the scope alongside it. Workspace is the previous
behaviour. Repository binds the rule to the repository the ask came from, so a
rule written in one checkout stops governing another. Session binds it to the run
you are watching and goes away with it.

An older Pushary never sees the narrower rules. A CLI that does not ask for them
is sent workspace-wide rules only, so a rule you scoped to one session cannot be
quietly applied everywhere by something released before the idea existed.

### AskUserQuestion reaches the phone with its descriptions

A Claude Code question carries a header, a description per option, and sometimes
permission to pick more than one. The phone received the bare labels. It now
receives the question the way the agent wrote it.

One question goes out as one card carrying the whole shape. Every surface that
does not know that shape, the web decision page and Slack among them, still sees
an ordinary choice list and answers with a label, which the server reads back onto
the question. Two or more questions go out one at a time, which is what every
surface has always been able to answer: a bare label cannot say which of several
questions it belongs to, and a half-filled answer is worse for the agent than
being asked again in the terminal.

### Codex and Gemini sessions started from the phone stay live

A phone-started Codex or Gemini run now goes through a bridge that survives the
connection dropping and resumes the same session instead of opening a new one.
The machine advertises only what it can actually do, so a Gemini CLI too old for
the bridge is not offered as one, and the phone stops showing controls that would
fail.

Stopping is acknowledged the same way. A stop request names the session and the
process it belongs to, and the machine proves it owns that process before acting
on it, so a recycled pid cannot be mistaken for the agent you asked to stop.


## 0.83.0 to 0.83.2

Three releases went out without an entry here. In order: `ask_user` became
idempotent, so a create that gets retried after a timeout resolves to the question
already asked instead of buzzing a phone twice. The pairing QR stopped encoding a
code only a browser could resolve. And the gate's two shared pieces, the verdict a
policy produces and the "most specific rule wins" precedence, moved into the
package the CLI and the server both read, with no change to what either decides.

## 0.82.0

### Setup reads properly when an agent is the one running it

An agent that runs `setup` relays its output to you, and two things in
that output were written for a terminal that was not there.

Colour was unconditional. Piped into an agent's transcript, ten raw
escape sequences arrived as literal text around the parts you needed to
read. Colour now follows the same rule the rest of the CLI already used,
so it is on at a terminal and off everywhere else.

The app download was unreachable until you gave up. "No app?" was
answered with the command for approving in a browser, which sends you
away from the phone at the moment you were being asked to install it,
and the download link appeared only after the fifteen minute wait
expired. The header now names https://pushary.com/download first and
says the wait continues while you install.

### The pairing QR is smaller

The QR encodes a link, and the link's length decides the QR's size. It
carried the pairing id and the public key, 114 characters, which drew 22
rows of blocks. Against a server that offers one, the CLI now uses a
short link instead: 33 characters, 16 rows, and small enough to survive
being relayed through something that truncates.

Nothing depends on it. Against an older server, or if the short link
cannot be issued, setup uses the long one exactly as before, and the
`pushary://` link is never shortened at all.


## 0.81.0

### The idle ping now honours a Terminal mode set in the dashboard

"Your agent is waiting" pushes checked your delivery mode by reading the
temporary override only. Choosing Terminal in the dashboard or the app
does not write an override, it writes a standing rule, so the check never
fired and the ping buzzed a phone whose owner had asked not to be reached.

The ping now declares itself a task update, which puts it behind the same
server-side gate as everything else the agent sends unprompted. That gate
sees the standing rule, the kill switch and a mute. It also means your
updates dial governs idle pings: set updates to Off and they stop.

## 0.80.1

### The bell's heartbeat directory is private to you now

`pushary bell` counts how many agents are live by keeping one empty file
per session in the system temp directory. On macOS that is already a
per-user directory, so this was fine. On Linux it is the shared `/tmp`,
where it was not: the first user to create `pushary-bell` owned it, and
every other user on the box got a permission error and silently counted
zero agents. Anyone who could write to it could also inflate the count
and trigger the upgrade line.

The directory is now per uid and `0700`, the heartbeats inside it `0600`,
and the sweep judges an entry with `lstat` so a planted symlink is dropped
rather than followed. Nothing about the bell itself changes, and it still
makes no network calls.

## 0.80.0

### A bell, free, for when you only need to know it finished

`npx @pushary/agent-hooks@latest bell` makes a noise in your terminal and raises
a desktop notification when Claude Code says an agent finished or is waiting on
you. No account, no API key, no network call. Nothing leaves your machine, and
`pushary bell --off` removes it without touching anything else.

If you run one agent, that is genuinely all you need and we would rather you did
not pay for more. Above three agents at once the bell says so, once a day, and
then stops talking, because a bell cannot tell you which of them is asking and
cannot reach you once you have walked away.

If you got here from `claude config set --global preferrednotifchannel
terminal_bell` and the `unknown option '--global'` error, the README now answers
that directly.

### Setup waits for you to tap, instead of trusting that a push went out

Setup used to finish on a delivered notification. A notification that arrives
and cannot be answered looks exactly the same at that point, and that is the
most common way this quietly breaks: approvals keep arriving and keep going
unanswered.

So setup now sends a real question and waits for you to answer it, and only says
`Setup complete, and proven.` once you have. If nothing comes back it tells you
rather than congratulating you. It only does this when someone is actually
there, which includes a pairing you just scanned, and never in CI. Pass
`--verify push` for the old behaviour, or `--verify none` to skip the check.

`pushary doctor --roundtrip` runs the same check and now says the same things
about it.

### Your agent works out when to reach you, without being told

The bundled skill used to describe itself only in terms of what you might say to
it: ping me on my phone, run this overnight. That only fires when you are there
to say something. It now also describes the moments that are true of the work
itself: about to do something irreversible, about to spend money or deploy,
blocked on a decision that is not the agent's to make, or a long task finishing
with nobody watching.

Everything it matched before, it still matches. Re-run
`npx @pushary/agent-hooks setup` to update the copy your agents already have.

## 0.79.0

### Your agent now says what kind of update it is sending, so you can route it

Pushary can now send task updates somewhere different from questions: approvals
can wait at your keyboard while completions still buzz your phone, or the other
way round. That only works if the agent says which one it is sending.

The instructions setup writes now tell your agent to pass a context type when a
task finishes. Without it a completion looks like any other notification, and
the setting you chose for task updates has nothing to act on.

Nothing changes for questions and approvals, and nothing you have configured
needs revisiting. Re-run `npx @pushary/agent-hooks setup` to update the
instructions your agents already have.

## 0.78.0

### Your agent can ask you several things at once, and reach your phone for all of them

An agent that stops to ask you something often asks more than one thing in the
same breath. Until this release the hook only recognised a single question, so
two or more meant nothing arrived on your phone at all and the whole exchange
waited at the terminal. That is exactly the moment this product exists for.

Up to four questions now come through in turn. The notification says which one
you are on, "(2 of 3)", so the first does not look like the last.

If any question goes unanswered, whether you deferred it, it timed out, or no
device was reachable, the entire exchange goes back to the terminal, including
anything you already answered. A partly filled answer sheet would leave your
agent guessing at questions it never put to you, and being asked twice is better
than being answered wrongly.

The whole exchange shares one wait budget. Four questions do not cost four times
the wait you agreed to.

Questions that let you tick several options at once still go to the terminal.
Those answers travel as a single piece of text and the packing is not something
this release can verify, so it is left alone rather than guessed at.

## 0.77.0

### Stop ends the process

Until this release Stop was a gate verdict. Every pending and new tool call was
denied, which is the guarantee and has not changed, but the agent kept running,
kept thinking and kept spending tokens. For someone who hits Stop because
something is going wrong, "it can no longer act" and "it stopped" are different
promises.

- every session event reports its own pid, so the server can name the process
- the daemon advertises a `stop` capability, which is how the server knows a
  machine can act on one at all
- the daemon applies stops from the drain with SIGTERM, and refuses to signal
  its own pid, since killing itself would take every other session's stop too

Two additive wire changes, both safe in either direction: `pid` on the event
payload, which an older server ignores, and `stops` on the drain response, which
an older daemon ignores.

## 0.76.1

### Actually closing the spawn argv injection

0.76.0 shipped under the message "closing the spawn argv injection" and did not.
It sanitised `model` and `cwd` but not `prompt`, and `prompt` is the field the
daemon passes as the token immediately after `-p`, so a flag-shaped prompt was
read back as a flag:

    ['claude','--remote','-p','--permission-mode=dontAsk'] -> {permissionMode:'dontAsk'}

`dontAsk` is a full-defer mode, so the gate stopped asking about every tool for
that session.

The server-side rejection landed in production first and is what actually
protects users, 0.76.0 included, with no upgrade required. This release carries
the defence in depth: `buildSpawnLaunch` returns null for a flag-shaped prompt,
so a daemon never builds that argv even if the server is bypassed.

## 0.76.0

### A phone-supplied field could switch your approval gate off

`pushary claude --remote` sessions started from the phone build their command
line from fields the request supplies. The wrapper then read that command line
back to learn what it had been asked to do, and it scanned every position without
knowing which ones were a flag's **value** rather than a flag.

So a prompt or a model of `--permission-mode=dontAsk` was read as a real
permission mode. `dontAsk`, `plan` and `auto` all defer to the agent's own prompt
unconditionally, which means the session stopped asking you for approvals
entirely, and the decision log recorded a session that was never gated. A clean
approval history that is not true is worse than a missing one.

Flag values are now consumed with the flag that owns them, so nothing a request
supplies can be mistaken for an instruction. A `--permission-mode` you passed
yourself still works, in both the `--permission-mode plan` and
`--permission-mode=plan` forms.

The server that queues these sessions now also refuses flag-shaped model and
working-directory values, so an out-of-date CLI is protected against those two.
It cannot do the same for the prompt, which is free text you are entitled to
start with a dash, so **updating is what closes this one properly.**

Reaching it needed an authenticated session on your own account, spawning to your
own machine, with the daemon running. It is not cross-tenant and not remote code
execution. It matters most where the person spawning is not the person governed:
Team approval routing, Partner, and any case where a phone session or API key has
been compromised.

## 0.75.0

### An agent can now run the install

Pasting "install pushary for me" into Claude Code, Codex or Cursor exited 3
before doing anything:

    ! No API key, and no terminal to sign in from.

The agent runs setup with stdin and stdout piped, so `isTTY` is false, and app
pairing sat behind the same check as browser login. That check was answering the
wrong question. Pairing needs a person, not a terminal, and the agent shows its
output to one, so a QR written to stdout is read just as well as a QR drawn on a
terminal.

Setup now pairs whenever it is not running in CI. It prints the QR, the tappable
link and the fingerprint, and waits for the scan, so the whole install is one
command with nothing to paste:

    npx @pushary/agent-hooks@latest setup

Browser login still requires a real terminal, because it opens a browser on the
machine and waits for a redirect. CI still declines to pair, so no build hangs
drawing a QR into a log nobody is watching.

The bundled skill told agents to do the opposite: hand the user a signup link
and wait for them to paste a key back. It now says to run setup and show the
user the QR.

The failure message above also had to change, because after this it was naming a
cause that no longer applies. Reaching that branch now means CI, `--skip-phone`,
or a connect mode other than `app`, so setup says which of those it hit instead
of blaming a missing terminal. A headless run with no flags is the one worth
calling out: it used to be told to drop into a browser login it cannot open.


## 0.74.0

### The terminal went quiet while your phone was deciding

A gated tool call sent the push and then blocked, writing nothing. Claude Code
showed a bare spinner. If you missed the notification there was no way to tell
that a question existed, let alone where to answer it, and the terminal prompt
only appeared once the wait expired.

The hook now says so before it blocks:

    [pushary] Sent to your phone. Waiting 10s, then this terminal takes over.
              Answer here instead: https://pushary.com/dashboard/agent

The wait line prints per question, because each question is its own blocking
moment with its own countdown. The URL prints once per session, because it is
the same page every time and repeating it on every gated call is noise.

That URL is the signed-in dashboard, not the `/decide` link the push carries.
The decide link is a bearer credential: anyone holding it can answer. Handing it
to the agent that is being gated would let it approve its own action, so the
hook is only ever told about the page that requires your login.

Servers that predate this send no URL, and the hook just prints the wait line.

## 0.73.0

### Codex could finish setup with no skill installed

If your machine already uses skills.sh, setup installs the Pushary skill
through that CLI so the install registers rather than being invisible. For
Codex, `skills add --agent codex` prints "copy to Codex", says "Installation
complete" and exits 0, then writes only `~/.agents/skills/pushary`. Nothing
lands in `~/.codex/skills`, which is where Codex reads. Setup took the exit
code as proof, skipped the copy it ships, and Codex ended up with no skill.

Doctor caught it, and then gave advice that could not work: clean and set up
again reinstalled exactly the same way and failed the same check.

The exit code no longer decides this. What decides it is whether the run left
a SKILL.md where that agent reads, which is the same thing doctor looks at,
and setup writes its own copy when it did not. Anything skills.sh really did
install is left exactly as it installed it, symlinks included.

Everything else about the wiring was fine, so this cost you the skill and
nothing more. Approvals, hooks and the instructions in `~/.codex/AGENTS.md`
were all in place.

### A skill that will not install no longer stops setup

The skill is the one piece of the wiring nothing else depends on. It used to
be able to abort setup partway, leaving an agent half configured. Now it warns
and setup finishes the rest, and `pushary doctor` tells you the skill is
missing.

## 0.72.0

### Setup tells you when Codex was already quarantined

0.71.0 stopped setup from getting your Codex binary deleted. This handles the
machines where it already happened. What macOS leaves behind passes every check
setup made: the `codex` on your PATH is a small JavaScript launcher and it
survives, `which codex` still answers, only the binary it launches is gone. So
setup wired up an agent that could not start and reported success.

It now says so before writing anything, and only when it can prove it: a vendor
directory that exists and holds nothing the size of a native binary. A layout it
does not recognise stays quiet, because telling somebody their working install is
broken is the worse mistake. Reinstall with `npm install -g @openai/codex` or
`brew install --cask codex`.

### Setup no longer runs the Hermes binary either

The same hazard, one agent over. When the config edit that enables the plugin
failed, setup fell back to running `hermes plugins enable pushary` — executing a
third-party agent binary, which is exactly what cost people their Codex install.
The fallback also did not work, because `hermes plugins enable` does not see a
pip install. It is gone, and a failed config edit now tells you the one line to
add by hand.

Executing an agent binary is now a build failure rather than a habit: every
source file in this package is scanned on every CI run, and the check knows the
difference between running `codex` and asking `which` where it is.

## 0.71.0

### Setting up Codex no longer gets Codex deleted by macOS

Setup ran `codex --version` to decide whether your Codex was new enough for
native hooks. On macOS, executing a binary hands it to XProtect, and current
definitions false-positive on the Codex CLI: macOS killed it and moved it to the
Trash. Reading a version number destroyed the tool it was asking, mid-setup, and
the only thing you saw was "codex was not opened because it contains malware".

Setup no longer runs Codex, or any other agent's binary, to learn something about
it. The version now comes from the package manifest next to the binary, which
covers npm, bun, pnpm and yarn installs, and from `brew list` for the Homebrew
cask, which ships no manifest. Same result, nothing launched.

## 0.70.0

### A key you name is the key you get

Passing `--key` with a malformed value did not fail. Setup read it as no key at
all, went looking elsewhere, and used whichever key it found: one already stored
on the machine, one in your environment, or a brand new one it created by pairing.
You named a credential and quietly got a different one. It now stops and says the
key is not valid.

### setup --json no longer stops to ask a question nobody can see

Asking for machine-readable output says plainly that no human is watching, but
setup could still reach an agent-selection prompt. Piped into a script it waited
for a keypress that was never coming, and the prompt drew itself onto the same
output a program was trying to read as JSON. It now selects the agents it detects,
which is exactly what pressing Enter on that prompt would have chosen.

`--json` also promises a single object of output, and every failing exit used to
print nothing at all. A refused key, a cancelled pairing and a crash were
indistinguishable to anything reading the output. Each now says which it was.

### Setup checks its own work

An agent installer that finished without an error was reported as configured,
even if nothing had been written. Doctor would then contradict setup minutes
later. Setup now confirms the configuration is really there, using the same check
doctor uses, so the two cannot disagree.

### connect points at the fix that matches your situation

`pushary connect --app` gave one answer for every way a phone can be out of
reach: open the app and allow notifications. That is right when the app is signed
in and the permission was declined, and wrong otherwise. A terminal that was
never paired needs setup. A phone signed in to a colleague's account needs your
account, not a permission toggle. It now says which applies, and when it cannot
tell the two apart it says both rather than sounding certain about the wrong one.

### The editor gates say when a proxy is in the way

The Cursor and VS Code gates cannot route through a corporate proxy. They still
hand the decision safely to the editor's own prompt, so nothing is blocked, but
every approval quietly stopped reaching your phone with no explanation. When a
proxy is configured they now name it as the likely cause, and on Node 24 or newer
they name the setting that fixes it.

VS Code is also now listed among the supported agents, which it has been since
its plugin shipped.

## 0.69.0

### An approval is never granted on a state Pushary could not confirm

When the hook could not reach Pushary, it read that as "you have not been
stopped, and you are under no agreed scope". Those are answers, and not reaching
the server is not an answer. A rule you had set to approve automatically could
therefore fire during an outage on exactly the kind of call a live scope would
have held back.

It now tells the two apart. If the last successful check was under a minute ago,
that answer still stands, so a brief network blip changes nothing, and a stop you
issued moments earlier still stops the agent. Past that, an automatic approval is
withheld and the decision goes to your agent's own prompt instead. Read only
shell commands are unaffected, because that list is decided on your machine and
needs no server at all.

A rejected key is treated as a real answer rather than a blip, so a key you
revoked stops being honoured at once.

### A scope you agreed to is now honoured everywhere, not just in one place

Scope was checked when Claude Code asked before running a tool, and skipped on
four other paths that could reach the same decision: Claude's permission dialog,
Codex, Gemini CLI, and the remote wrapper. Each of them fetched the scope you had
agreed to and then ignored it, so the same edit could be waved through depending
only on which route it arrived by. All five now run the same check in the same
order.

### Codex patches are judged on every file they touch

A Codex patch that changes several files at once was judged on the patch as a
whole, so a rule you wrote for one file did not apply when that file was part of
a larger change. Every file is looked at now, and the strictest rule wins. Rules
written as paths also work for Codex and Gemini, which they quietly did not
before.

### Command details that could contain a secret no longer leave your machine

A command like `GH_TOKEN=... gh pr create` carries its credential in the first
few words, and those words were being sent and stored as the label for what the
agent wanted to do. Notification text was not being cleaned at all. Both are
cleaned now, on your machine and again on arrival, and the Cursor and VS Code
gates clean the command before it is sent rather than after.

### setup --dry-run really does change nothing

On a machine with no key, a dry run reached the pairing step, drew a QR code,
waited for your phone, created a real API key, and then said nothing had been
written. It now stops before anything is created and tells you a real run would
connect a key first.

### clean can no longer leave Cursor blocking your commands

Cursor is told to block a matched command if the Pushary gate cannot answer, and
that list includes ordinary work like rebase, migrate, deploy and publish. Clean
removed the gate's files before removing that instruction, so if the second step
failed, Cursor was left blocking all of them on a file that no longer existed,
under a message saying clean had finished. The order is reversed and checked, and
if the instruction cannot be removed the files stay put, clean says so, and it
exits with an error.

### Pairing survives a dropped reply

If the reply carrying your key was lost in transit, the terminal reported that
pairing had expired and you started again, while the key it never received stayed
on your account. The key is now held until your terminal confirms it has it, so
retrying finishes the pairing you already started. A terminal that cannot reach
Pushary at all now says so instead of sitting under a QR code for fifteen
minutes.

### doctor reports on the agents you actually use

Doctor checked Claude Code whether or not you had it, so setting Pushary up for
Codex alone produced a column of failures about software you had never installed.
It now reports on the agents Pushary is set up for, and says plainly when it is
set up for none.

Three things it used to call healthy, it no longer does. A key that lives only in
a shell profile cannot be read by any hook, so an install where nothing could
work was passing every check. A key saved inside Claude, Cursor or VS Code that
no longer matches the one in use, which happens whenever pairing issues a new
one, went unmentioned. And when doctor could not reach Pushary at all, it said
everything was fine.

Its exit codes now distinguish a broken setup from having no phone connected from
not being able to reach Pushary, so a script can tell what went wrong. A test
question nobody answers is now withdrawn instead of sitting on your phone.

## 0.67.0

### The VS Code agent can now ask for approval on your phone

Setup has a VS Code option. It installs a Pushary agent plugin, connects the MCP
tools so the agent can notify you and ask you questions, and registers a
permission gate so a risky terminal command reaches your phone before it runs.
The policy behind it is the one your other agents already use, so a rule you
wrote for Claude Code applies here too.

Two things about VS Code shaped how this works.

VS Code reads a hook's matcher but does not act on it, so the gate is called for
every tool the agent uses, including reading a file. It therefore decides for
itself, and for anything that is not a risky shell command it returns straight
away without touching the disk or the network.

And a plugin folder does nothing until VS Code is told where it is. Setup adds
that entry to your settings.json. That file usually has comments in it, and
rewriting it as plain JSON would delete every one of them, so an existing file
is edited one line at a time and left otherwise byte for byte identical. If your
settings already have a plugin list with comments around it, setup does not
guess: it prints the single line to paste and names the file.

Unlike Cursor, VS Code hooks cannot ask the editor to block a command on the
plugin's behalf. So when Pushary cannot reach you, or cannot reach its own
server, the gate hands the decision to VS Code's own approval prompt rather than
letting the command through.

`pushary clean` removes all of it again, the settings.json entry included, and
`pushary doctor` checks the three things that can be silently wrong: whether the
plugin is registered at all, whether the gate script resolves, and whether your
key is linked.

## 0.66.0

### Setup waited three minutes for a step that takes longer than three minutes

If you already had the app installed and signed in, pairing resolved in seconds
and this never came up. If you did not, it could not work at all.

Scanning the QR without the app opens a page that sends you to the store. Install
it, sign in, subscribe, come back, scan: that is never three minutes. The CLI had
given up and cancelled the pairing long before, so a first-time user's first scan
was guaranteed to fail, and the only route through was to notice the terminal had
stopped waiting and run setup again.

Setup now waits as long as the code is actually valid, and after the first
45 seconds the spinner says what to do if the app is not installed yet. Ctrl-C
still exits cleanly and cancels the pairing, so nothing holds you there.

The window itself is unchanged in what protects it: a pairing id is 128 bits,
single use, deleted the moment it is claimed, and authorising one requires a
signed-in session for the owning account and the public key you physically
scanned.

## 0.65.0

### The pairing QR now works when you scan it with your phone camera

It did not. Scanning it opened the app on "Unmatched Route", and the only way
through was the in-app scanner, which parses the QR itself and never routes.

The QR encoded `https://pushary.com/app/pair`, and `/app/*` is claimed by the
app: the AASA lists it and so does the Android manifest. The OS therefore handed
the link to the app before the page could load, which was the intent. What it
handed over was the path `/app/pair`, and the app's pairing screen is `/pair`.
Nothing matched, on every build ever shipped.

The QR points at `/pair` now. Nothing claims it, so the browser always opens it,
and the page hands off to `pushary://pair`, which every shipped build already
routes correctly. That is the whole reason for the move: the Android claim is
compiled into the installed app, so no amount of server or app-store work fixes
the phones already out there, and this does, today.

`/app/pair` still serves the same page, so a link from an older CLI is not dead.

## 0.64.4

### The shell you ran setup in still exported the key it replaced

0.64.3 stopped a stale export from hiding the working key on disk, and the rc
file has been rewritten in place since 0.62. Neither reaches the shell you are
standing in: the export there is still the old key, the environment wins over the
config file at hook time, and the agent you start in that terminal a minute later
talks to whichever workspace that key belongs to.

`--connect app` mints on every run and is the command onboarding prints, so this
is the ordinary case rather than an exotic one. Setup now says so under "Still to
do": open a new terminal, or re-export. No key is printed in that line.

## 0.64.3

### A stale shell export hid the working key on disk

`PUSHARY_API_KEY` wins over `~/.pushary/config.json`, which is right and
unchanged. But the environment used to win on the key's *shape* alone, so a
well-formed key the server had since revoked shadowed a working one in the config
file and setup exited "This key was rejected by pushary.com" with a perfectly
good key sitting on the machine.

That is the state any shell left open across a key rotation is in: the export is
the old key, the file is the new one, and the terminal you happen to be typing in
decides whether setup works.

The environment key is checked with the server before it is chosen now, and a
rejected one falls through to the stored key rather than ending the run. It costs
no extra call: the verdict is handed to the verification step that was already
making it.

### `--connect app` on a machine with no terminal named the wrong problem

A headless run that asked to pair said "No API key, and no terminal to sign in
from" even with a key in the config file, because asking to pair deliberately
takes the stored key off the table. It now says the flag needs a terminal for the
QR, and that dropping it uses the key already there.

## 0.64.2

### A run that gave up reported nothing, and said it stopped at the start

`agent_setup_completed` was wired to one of setup's fifteen exits: the last line
of a run that finished. Every other way out returned before it. A key the server
refused, a cancelled pairing, no terminal to sign in from, an installer that
threw, a Ctrl-C at the agent checkbox: all silent. The beacon was added so failed
and abandoned runs would stop being invisible, and the runs it could see were
still only the ones that already worked.

Every early exit reports now, and each says why it stopped: `key-refused`,
`pairing-cancelled`, `no-key`, `login-rejected`, `no-agents-detected`,
`invalid-key`, `interrupted`, `crashed`.

Argv-time exits stay silent, along with `--help` and `--dry-run`. A flag typo is
not an abandoned run: nothing was attempted and nothing was written. It is also
the fastest-failing path there is, and waiting on a beacon took `setup --bogus`
from 60ms to two seconds against an unreachable server. A dry run stays silent
for the other reason: counting it as a completed setup would be its own lie.

`abandonedAt` said where. It was always `start`, because `markStep` had no call
site outside its own test, so the field that was supposed to locate a failure was
a constant. It now moves through the run, per agent inside the configure loop, so
an installer that hangs is named rather than grouped.

Ctrl-C at the pairing QR reports before the process leaves. The cancel and the
beacon run concurrently and the exit waits for both, so this adds nothing to the
worst case a cancel already had.

The beacon authenticates with the key the run resolved rather than the one
already installed. Most of these exits happen before the key is written to disk,
so without that they posted as nobody, which meant the window holding the
interesting failures was exactly the window that could not report.

## 0.64.1

### Ctrl-C at the pairing QR printed nothing

The QR screen said "No app? Press Ctrl-C for the ways to finish without one", and
pressing it printed nothing at all. `connectViaAppPairing` handles SIGINT by
exiting the process itself, so control never returned to setup and the lines it
meant to show were unreachable on the one path that advertised them.

Setup now hands the escape to the pairing wait, which prints it inside the
handler before exiting, and the line on screen names the command directly instead
of telling you to interrupt the program to find out what it is.

The same two commands are printed when a pairing simply never completes, so both
ways out of the QR screen say the same thing.


## 0.64.0

### A typo on `--dry-run` ran a real clean

`pushary clean --yes --dryrun`, one missing hyphen, silently dropped the flag it
did not recognise and performed a full destructive clean: the stored key, the
Cursor plugin, every agent config, and the global package itself. It printed
"Clean complete." with the past tense throughout and exited `0`. The typo most
worth catching was the one on the flag that means "change nothing", sitting next
to the flag that means "do not ask me".

`clean` now rejects a flag it does not know and exits `2` before removing
anything. `--dry-run`, `--yes` and `-y` are unchanged.

### Setup reuses the key this machine already has

`setup` read `PUSHARY_API_KEY` and never `~/.pushary/config.json`, so the key
`pushary login` had just written was invisible to it. `login` followed by `setup`
in the same shell ran a second browser login and minted a second key, and a
machine with no terminal that had been set up by `login` exited "no API key" with
a working key sitting on disk. The shell export made it work in a *new* shell,
which is what hid it.

Setup now reads the stored key, verifies it, and uses it. A key the server no
longer recognises falls through to a fresh sign-in rather than dead-ending, and a
server it cannot reach keeps the key rather than discarding it.

### The Pushary app is the default connect path

An unflagged `setup` now pairs the app instead of sending you to the browser
subscribe page. `--connect browser` is the fallback for a machine with no app,
`--connect web` is the legacy page and keeps working exactly as before, and
`--connect none` skips the phone step like `--skip-phone`. `auto` and `pwa` are
accepted as input spellings.

Pairing only happens when there is a terminal to show the QR to and no usable key
already on the machine, so re-running setup to add an agent no longer asks you to
scan anything, and no longer mints a key you did not ask for. Typing
`--connect app` explicitly still pairs every time.

When pairing does not complete, setup now names the two ways to finish without
the app rather than only inviting you to try the QR again.


## 0.63.2

### Setup reports what happened, not only that it worked

The setup event fired only when at least one agent had been configured, so a run
that configured nothing, failed, or stopped partway reported nothing at all. The
only visible runs were the ones that already worked.

It now fires once per run whatever the outcome, and carries the connect mode and
its result, how many agents failed, which were skipped, whether the run was
non-interactive, and where a run stopped if it did.

This is the same event that already powers your activity feed, sent with your own
API key to your own workspace. No repo path, hostname or agent content is
included, and nothing is sent before you have a key.


## 0.63.1

### `pushary status --bogus` printed a stack trace

An unknown flag on `status` threw where nothing was catching it, so Node printed
a raw stack trace and exited `1` instead of the clean usage error and exit `2`
every other command gives. Other commands were unaffected.


## 0.63.0

### Machines

`pushary daemon` now reports itself to your workspace once a minute, and
`pushary status --devices` lists what is there:

```
  Machines
  ● macbook-pro     0.63.0  just now
  ○ build-server    0.62.6  4h ago
```

`online` is worked out from the last heartbeat rather than stored, so a laptop
that closes mid-session reads offline a few minutes later without anything
having to notice it left.

This is what lets a phone start a session on a machine you name, instead of on
whichever one happens to answer first.

Presence is best effort throughout. A heartbeat that fails never stops the daemon
draining its queue, and `status` still reports your key and channels if the
machine list cannot be read. Against a server that predates the endpoint,
`--devices` says so rather than reporting an empty list.

**Needs a database migration** (`0066`). Until it is applied, `--devices` reports
that it could not read the list, and nothing else is affected.


## 0.62.6

### `clean` rewrote your project's Cursor config and left your own alone

`clean` built the Cursor MCP path without a home directory, so it resolved
against whatever directory you ran it from. Inside a repo that has a
`.cursor/mcp.json`, it rewrote that committed file, stripping the Pushary entry
and reformatting the rest. Meanwhile your real `~/.cursor/mcp.json` was never
touched, so the API key stayed in it.

Check `git status` in any project you ran `clean` from, and check
`~/.cursor/mcp.json` for a key you thought was gone.

### Every file the CLI touches is now named in one place

`setup`, `clean`, `doctor`, `logout` and `upgrade` each derived the same paths by
hand, and they drifted. That drift caused this bug and the `CODEX_HOME` one
before it. All twenty-three call sites now resolve through one module, and a test
fails any command that starts spelling a path out again.

No behaviour changed anywhere else: setup's output is byte-identical across every
mode before and after.


## 0.62.5

### `clean --dry-run` changed things again

0.62.4 moved the dry-run guards into their own module and passed the flag in by
value. That happened at module load, before the flag had been parsed from the
command line, so every guard was built in live mode and stayed there. A dry run
stripped the Codex key, rewrote agent configs and removed the plugin directory,
while printing "would" beside each one.

The guards now read the flag when they run rather than when they are built, so
there is no longer an order in which they can be created too early.

If you ran `clean --dry-run` on 0.62.4, treat it as though you ran a real clean:
re-run `setup` to reconfigure.


## 0.62.4

### `pushary clean` crashed partway through on Node 24

`clean` threw `ERR_INVALID_ARG_TYPE: The "options.force" property must be of
type boolean` and died mid-run, after cleaning some agents and before touching
the rest. Introduced in 0.62.0 by the `--dry-run` work, which started passing
`force: undefined` to `rmSync` on every call that omitted the option. Older Node
ignored that; Node 24 rejects it.

If a clean died on you, re-run it. It is safe to run repeatedly and picks up
whatever is left.

The three mutation primitives behind `--dry-run` now live in `src/cli/dry-run.ts`
instead of inside the command file. Both `--dry-run` defects that reached users
were in those few lines, and both got past tests that could only read the source
as text, because code in `bin/` runs at import and cannot be called by a test.
They are ordinary functions with ordinary tests now.

### The authorize page's code input overflowed its card

The eight character slots were a fixed width, so they added up to 488px inside a
384px card and spilled out of both edges. They now share the row and shrink to
fit, down to a 320px phone.


## 0.62.3

### `setup --connect app --key ...` discarded your key

Pairing was checked before the supplied key, so passing `--key` or `--key-stdin`
alongside `--connect app` silently threw that key away and minted a new one. The
key you named stayed live on the account with nothing pointing at it. An
explicitly supplied key now wins, and pairing is only the key source when there
is no key to use.

Pairing still mints a key each time it runs, which is inherent to the protocol.
When that replaces a key this machine was already using, setup now says so and
names the old key, rather than leaving you to find it in the dashboard later. It
does not revoke it for you: the same key may be in use on another machine.

### The update banner offered downgrades

Setup compared its version to the registry with `!==`, so it announced an update
whenever the two merely differed. A machine running a build ahead of npm was told
`Update available: 0.62.3 -> 0.62.2` and pointed at a command that would have
downgraded it. It now prompts only when the registry is genuinely ahead, using
the same comparison `pushary upgrade` already used. There is one implementation
of that comparison now instead of two.

### A reused key skipped its own verification

Setup skipped the server key check whenever `--connect app` was passed, on the
assumption the key had just been minted. That is only true when pairing actually
produced it, so a key reused on an `--connect app` run was never verified. The
skip now follows where the key came from.


## 0.62.2

### An ignored `--dry-run` made real changes

`mode`, `wait`, `suggestions` and `status` read their arguments positionally and
silently discarded any flag they did not recognise. So `pushary mode push_only
--dry-run` dropped the flag and changed the mode for real. Those four now reject
an unknown flag and exit `2`, and they do it before looking for your API key, so
a typo is reported as a typo rather than as a missing key.

### The proxy support was never switched on

`HTTP_PROXY` and `HTTPS_PROXY` handling was written but never called from
anywhere, and Node's fetch ignores both without it. Behind a corporate proxy,
setup verified your key through the one code path that used it, reported
everything healthy, and every approval afterwards failed. It is now installed by
the shared HTTP layer, the MCP transport and the command dispatcher.

### `doctor --json` woke your phone on every run

The test push was skipped only for `--no-push`. Running `pushary doctor --json`
on a schedule sent a real notification each time. It is now opt-out for a person
at a terminal and off by default for `--json` or a non-TTY. Pass `--roundtrip` to
send one deliberately.

### Browser login could stall on the plan step

Choosing a plan navigated the same tab away from the authorize page, and that
page was the only thing that could finish the login once the plan activated, so
the terminal waited on a promise nothing was going to keep. Checkout now opens in
a new tab and the page completes the login by itself. The CLI no longer claims it
finishes on its own without saying the tab has to stay open.


## 0.62.1

### `pushary clean --dry-run` was not a dry run

In 0.62.0 the flag ran four real operations while printing "would":

- uninstalled the global `@pushary/agent-hooks` package
- rewrote your shell profile to remove the `claude` alias
- stripped the Pushary block from `~/.codex/AGENTS.md`
- stripped it from `~/.gemini/GEMINI.md`

If you ran `pushary clean --dry-run` on 0.62.0 and the CLI then seemed to
disappear, that is why. Reinstall with `npm i -g @pushary/agent-hooks`, and check
your shell profile and those two files against version control if you keep them
there.

### `pushary upgrade` could destroy `~/.claude.json`

`upgrade` treated an unparseable config as an empty one and wrote it back, so a
truncated `~/.claude.json` was replaced with nothing but the Pushary MCP entry,
losing every project, MCP server and permission rule in it. A half-written
`~/.claude.json` is what an interrupted write leaves behind, so this needed only
one earlier crash to line up.

A config that cannot be parsed is now left untouched, and the other agent files
are still refreshed around it.


## 0.62.0

### `pushary clean` could leave your API key on disk

If `CODEX_HOME` was set, `clean` looked for Codex's `config.toml` in `~/.codex`
and never found the real one. It printed `Codex config (not found)`, exited `0`,
and left the key in the file. A user who ran `clean` to remove their key was told
it had worked.

The same fault ran through the rest of the Codex path. With `CODEX_HOME` set:

- `setup` wrote the MCP server, the trust hashes and the notify entry to
  `~/.codex/config.toml` while writing `hooks.json`, `AGENTS.md` and the skill to
  the real `CODEX_HOME`, splitting the install across two directories
- `setup --dry-run` named the relocated path, so the preview and the real run
  disagreed about where the key would land
- `doctor` read the relocated config and reported the key missing
- `logout` treated an exported-but-empty `CODEX_HOME` as a path relative to the
  current directory

If you use `CODEX_HOME`, run `pushary clean --dry-run` to see what is still
there, and check `$CODEX_HOME/config.toml` for a key you thought was gone.

### New

- `pushary clean --dry-run` lists everything it would remove and changes nothing
- `pushary doctor --bundle` writes a redacted diagnostics file for a support
  thread. Keys, your home directory and the machine name are stripped.
- `pushary logout --revoke` kills the key server-side as well as locally, so it
  stops working everywhere rather than only on this machine

### Changed

- `pushary upgrade` now refreshes Codex, Gemini CLI and Cursor, not only Claude
  Code, and names the agents it actually touched. It matters most for Codex,
  whose hooks are trusted by hash: a refreshed hook command without a refreshed
  trust entry is a hook Codex silently declines to run.
- `pushary upgrade` with no global install now names the install modes it can
  see and the command that updates each, instead of one generic line and exit 0
- `pushary daemon` stops on a rejected key or a closed plan and exits `4`
  (`UNAUTHENTICATED`). It used to treat that like a network blip, back off to a
  two-minute poll and sit there under an `online` banner, so a revoked key left a
  daemon that looked healthy and would never launch anything again.
- Ctrl-C during app pairing now cancels the pairing on the server, so a QR still
  on your screen stops being claimable immediately instead of staying live for
  the rest of its five minutes
- `setup` prints the command surface when it finishes

## 0.61.0

The largest release since the CLI shipped. Setup no longer asks you to paste an
API key, the key is checked against the server before anything is written to your
machine, and commands exit with codes that mean something.

### Read this first: exit codes changed

Commands that used to print a failure and exit `0` now exit non-zero. If you run
any of these in CI or chained behind `&&`, this is the part that affects you.

| Command | Was | Now |
| --- | --- | --- |
| any unknown command or `pushary` typo | `0`, printed help | `2` |
| `doctor` with a failing check | `0` | `5` |
| `setup` with a revoked key | `0` | `4` |
| `setup` with an expired or locked plan | `0` | `5` |
| `mode`, `wait` with a rejected key | `0` | non-zero |
| `status` with no key configured | did not exist | `3` |

The full ladder: `0` ok, `1` failed, `2` usage, `3` not configured,
`4` unauthenticated, `5` problems found, `6` no device, `7` input required,
`8` unreachable, `130` interrupted.

`pushary doctor && deploy` used to succeed against a machine doctor had just
declared broken. That is the change worth knowing about.

### Sign in instead of pasting a key

`pushary setup` opens a browser, shows you a code, and receives the key sealed to
a keypair generated on your machine. The key is never pasted, never shown in a
terminal, and never transits in a form the server could read.

- `pushary login` and `pushary logout` as standalone commands
- Pasting a key still works, as a fallback and via `--key`
- `--key-stdin` reads the key from stdin, so it stays out of your shell history
  and out of the process list

### New commands

- `pushary status` reports what this machine is connected to, with `--json`
- `pushary login`, `pushary logout`, `pushary connect`

### Setup

- The key is verified against the server before a single file is written. A
  revoked key or a locked plan stops the run instead of producing a config that
  looks finished and never delivers.
- `--yes` runs setup with no prompts, for CI and images
- `--agents claude_code,codex,...` picks agents without asking
- `--dry-run` prints what it would do and touches nothing
- Agent detection is real, so an agent you do not have is reported as skipped
  rather than counted as configured
- Ctrl-C in a trailing prompt no longer reports the whole run as failed
- A stale `PUSHARY_API_KEY` export in your shell profile is now replaced rather
  than skipped. It used to win forever over a newly rotated key.

### Delivery is proved to your phone, not just to the workspace

Setup and doctor now check that an approval reaches **you**, not merely that
somebody in the workspace has a device. On a shared site where the only reachable
channel cannot be attributed to you, that is said plainly rather than reported as
success.

### Diagnostics

- `pushary doctor --json` for one machine-readable envelope
- `pushary doctor --roundtrip` sends a real approval and waits for the tap. It
  works without a TTY, where the old confirm prompt could not run at all.
- `pushary doctor --no-push` for a check that stays silent
- doctor reads the same key the hooks will actually use, honoring
  `PUSHARY_CONFIG_FILE`, and reports a Claude or Cursor config holding a
  different key

### Correctness

- `--help` and `--version` print and exit instead of running the command.
  `pushary daemon --help` used to start a daemon; `pushary hook --help` used to
  block forever on stdin.
- Unknown flags are rejected on the commands that parse them, instead of being
  coerced into a plausible wrong value. `--connect` with an unrecognized mode used
  to silently mean `web`.
- Every file this CLI writes is written atomically, so an interrupted setup can
  no longer truncate a large `~/.claude.json`
- An unparseable agent config is left alone instead of being overwritten
- Files holding your key are created `0600`
- `CODEX_HOME` is honored when locating `config.toml`
- Hermes setup works on Windows
- The Cursor plugin is staged and verified before the old one is replaced
- `installGlobally` pins the exact running version instead of drifting to latest
- `pushary clean` removes only the export line it wrote, not every line in your
  shell profile that mentions the key variable
- Requests honor `HTTP_PROXY` and `HTTPS_PROXY`
- Color honors `NO_COLOR` and `FORCE_COLOR`, and turns itself off when output is
  not a terminal
- `--json` output is machine-readable on stdout, with human text on stderr, so a
  pipe never receives a spinner

### Pairing

The pairing QR now points at a real web page, so scanning it on a phone without
the app installed lands on install instructions for that platform instead of a
dead custom-scheme link.

### Known gaps

- `status`, `clean`, `mode`, `wait`, `stats`, `suggestions` and `upgrade` still
  ignore an unknown flag rather than exiting `2`. Unchanged from 0.60.0.
- `pushary upgrade` reapplies Claude settings only. Other agents need `setup`.
- Codex hook trust still uses a fixed version ceiling, so a newer Codex is sent
  to the manual `/hooks` step.
