# sessionpipe changelog

Every release of the four packages (one shared version number), newest first, generated from the repository's changesets.

## 0.15.1 (next, on main, not released yet)

- **patch** conformance: a fixture run reads only the folder it was given, and holds an event's data to the fixture exactly
  
  Four changes to `sessionpipe-conformance`, from a security scan of the repository's tooling.
  
  An event's `data` now has to match the fixture field for field: a field the adapter sends that the fixture does not name is a miss (`[0].data.output: not in the fixture`). Before, the fixture's fields were compared and anything extra passed, so an adapter could start sending more than its fixtures vouch for and still read as conformant. `"*"` is still a wildcard for a value, and a session's facts are compared as before (the fixture's fields; a harness's environment adds others).
  
  A symbolic link under the folder you name is an error that names the link (exit code 2), instead of being followed to a file or a folder somewhere else on the machine.
  
  A fixture whose fields are the wrong kind (`input.argv` that is not a list of strings, an `expect` entry that is not an object, an `env` that is not a map of strings) is a `FAIL` row that says which, instead of an exception that ends the run.
  
  Control characters in a fixture's path or in an error are printed as `\xNN`, so a file name cannot move the cursor or recolour the terminal of whoever runs the suite. ([2b42b0d](https://github.com/spacesheep-dev/sessionpipe/commit/2b42b0d))

## 0.15.0 (2026-11-13)

- **minor** cli: output a person can read at a glance, and one place that says whether this computer is fine
  
  `sessionpipe doctor` now starts with a verdict (`All good`, or how many things need attention), sets out what it looked at in groups of aligned rows, each with a mark and a word, and ends with **To fix**: one plain sentence and, where there is something to run, one command each, in the order to run them. `sessionpipe status` is the same in a few lines. `--json` keeps its shape on both.
  
  Trouble that only the daemon's log knew is on that page: a disk that filled up, a key the service can't read from the keychain, a pool with no account left to try, a receiver that hasn't answered. Whether Claude Code is signed in is asked of the running service over its socket (a new, read-only `health` request), because a shell over ssh can't read a Mac's login keychain; before, doctor told people who were signed in to sign in again. A service that launchd or systemd doesn't have at all is said to be not loaded, where it used to read as offline.
  
  `sessionpipe control status` is a page at a terminal, and exactly the JSON it always printed when piped or given `--json`. `--help` is one page of every command in groups, each with what it is for in a line that fits 80 columns; a command's own `--help` folds its usage between options.
  
  Colour comes from one small module with no dependency: on at a terminal, off there with `--no-color`, `NO_COLOR` or `TERM=dumb`, on there with `FORCE_COLOR` whatever `NO_COLOR` says. A pipe is never coloured. Nothing is said by colour alone. Lines fold at the terminal's width under their own text, never inside a command; a pipe gets whole lines, as before. Nothing a program reads has changed: hook output, the worker, the status line, every `--json`, the daemon's log.
  
  What changes for a script, and what does not:
  
  - **`doctor`'s exit code.** `sessionpipe doctor` exits 1 when **To fix** holds a command to run, and 0 otherwise; it used to exit 0 always. A finding with nothing to type (a slow hook, a receiver that hasn't answered yet, a disk that was full and has room again, hooks an agent's own setting turns off) is on the page and counted in the verdict, and leaves the code at 0, so the code does not change with this computer's load. `doctor --json` exits 0 whatever it finds, as before. `status` exits 0, as before.
  - **A command that fails.** Where stderr is a terminal it prints two lines: `error` with what is wrong, and `try` with the one command to run next. That command is the one the code that failed names, or the failing command's own `--help`; it is never read out of the message, which may be a receiver's or npm's words. Where stderr is not a terminal it is the one line it always was, `sessionpipe: <message>`, and exit 1.
  - **`control pair` when a program runs it.** Not at a terminal its lines are the ones it has always written, to the byte: the link, `The page shows these digits too: 123456` or `enter:  123 456`, `✓ Paired with …`, `Daemon: …`. At a terminal the link and the digits stand each on a line of their own (`open:`, `check:` or `enter:`), and the last line starts `Background service:`.
  - **Lines that are worded anew, at a terminal and in a pipe.** `doctor`, `status` and `--help` are new pages, and `control mode` sets each mode out on its own line. `install` says `This computer: <name>` where it said `Machine: <name>`, `No coding agent found on this computer` where it said `No supported harness found on this machine`, and `Nothing leaves this computer yet` where it said `this machine`. `ask on`, `ask off` and `pool on` say `Background service: …` where they said `Daemon: …`. `Renamed the background service`, `The background service uses the new list within seconds` and `The background service uses the new setting within seconds` said `the control daemon`. `sink list` and `pool` keep their old lines in a pipe and get a layout that fits at a terminal. The line `ask <agent>` writes to stderr when the service goes away is unchanged.
  - **`--no-color`** is taken out of the arguments wherever it stands, before the command reads them, so it is never the value of the option before it.
  - **`doctor --json`.** The shape is the same. `claude_accounts[].signed_in` is now what the running background service sees, where it can be asked, and this shell's view only where it can't. `warnings` keeps its old strings and may hold three new ones: `the background service isn't loaded: <launchd|systemd> doesn't have it`, `this computer's disk is full: <n> MB left` (or `this computer's disk was full <when> ago, by the background service's log`), and `the pool has no account with room left: Claude Code sessions here are being refused`. `update.off` keeps the words it had.
  - **Words that are not sessionpipe's own** (a receiver's reason or last error, npm's last lines, a name from a file) are shown as words: escape sequences, other control characters and the characters that turn the writing direction round are taken out of every line written for a person, and a line break inside such a value is a space.
  - **Commands printed for pasting** (under **To fix**, and after `try`) quote a path, a name or an address a shell would otherwise act on.
  
  `sessionpipe-core/service` gains `serviceState`: whether launchd or systemd has a service now, without changing anything. ([6dd3f98](https://github.com/spacesheep-dev/sessionpipe/commit/6dd3f98))
- **minor** update: one command leaves the whole computer current
  
  `sessionpipe update` used to update only the copy it ran from and ask the daemon to restart "as soon as nothing is in hand". On a Mac with two copies (the one typed, and another the background service ran) the typed command stayed at an old version while everything looked updated; on a computer where some session is always working the restart never came, and people were told to type `launchctl kickstart`.
  
  Now, when it ends well, the copy you type, the copy the background service runs and the hooks are one version, the newest; the service is running it and has heard from its receivers; and the last line says so. It installs npm's latest into this copy, writes the hooks again from it, puts the service on this copy, and looks that it answers with the new version and has had an answer from each receiver since it started (it waits up to 40 seconds for that, the time a long-poll's first answer can take).
  
  How the service gets onto this copy depends on who runs it. One that launchd or systemd has, and that runs another copy or doesn't answer, is replaced with the safe replace from 0.13.7; it keeps the environment its service file gave it (its PATH, a `SESSIONPIPE_CLAUDE` added by hand), and only the program changes. One whose file is there with nothing running is put back. One that runs this copy restarts itself. One you run yourself (`control pair --no-service`, tmux, a container, WSL without systemd) is asked over its socket to restart itself, and no service is installed that nobody asked for.
  
  When it can't get there it stops with one line that says what is wrong and one that says what to run next: `sessionpipe update` again when npm can't be reached (what needs no download is still done, and nothing is ever moved back to an older release); a `sudo npm install … && sessionpipe update` line for a prefix this user can't write, with the prefix quoted (on Windows: `sessionpipe update` from a terminal opened as administrator); `npx sessionpipe@latest update` for a copy npm didn't install; `sessionpipe control run` where nothing runs the service and there is no service file for it. Where launchd or systemd refuses the new service, the one that was there before is put back and started again, and the command says whether anything answers now.
  
  Exit 0 means everything is current, 3 that the service finishes by itself, 1 that it stopped. A receiver that hasn't answered the service is not a failed update: the last line says the computer is up to date and names the receiver, in yellow, and the exit code is 0. `SESSIONPIPE_NO_UPDATE_CHECK=1 sessionpipe update` changes nothing, as before: it says that updating is off because the variable is set, and how to run it once without it.
  
  A restart is now held back only by what the daemon itself holds: a turn it runs, a message in hand, a permission prompt it holds for an answer, a pooled request being answered. A session someone is working in, mid-turn in its own window, no longer holds it back: that it is mid-turn is written to the daemon's state at a stop and read back at the next start, so a message to it still waits for its Stop. `update` gives the daemon 25 seconds to finish what it holds; past that it says the service restarts by itself when that is done, and there is nothing more to type. Just before it replaces a running service it asks it to stop taking messages, in the same step in which the service is found to hold nothing. A service from before 0.15 does none of this, and restarts itself only once no session on the computer is mid-turn: where launchd or systemd has it, `update` replaces it at once, after writing down for the new one which sessions the old one knew to be mid-turn; one run by hand can only restart itself by its older rule, and `update` says so and exits 3. Into a pipe a failed `update` is one line that ends `Next: <command>`.
  
  The daemon's own daily update takes the same steps (`installEverywhere`): its own copy, the `sessionpipe` on its PATH when that is another install (the copy a person types), the hooks, then its restart. npm is only ever run on a folder that has `dist/cli.js` and a package.json that says it is sessionpipe. `sessionpipe doctor` sees a split between the three, says which, and names `sessionpipe update` as the one fix; it no longer sends anyone to `sessionpipe install` from an older copy, which was refused or, forced, put the hooks back. Where the service isn't running and there is no service file for it, `doctor` and `update` both name `sessionpipe control run`.
  
  Also: the registry asked for `latest` is the one `npm_config_registry` names, when it names one, since that is where npm installs from. A sessionpipe that keeps its files somewhere other than this computer's usual place (`SESSIONPIPE_STATE`, `SESSIONPIPE_CONFIG`, `XDG_*` or `HOME` point elsewhere: a test, a throwaway world, a profile of your own) never looks at, replaces or stops this computer's usual service, and says so; `SESSIONPIPE_SERVICE_LABEL=org.sessionpipe.<name>` gives it a service of its own, and no other name is taken.
  
  `sessionpipe-core/service`: `serviceState` also answers the program a service file names (`serviceArgv`), and says `manager: null` where launchd or systemd can't be asked from this process; `serviceEnvironment` reads back the environment a service file gives; `installService` restarts a systemd unit that was running onto the new file, and where the manager refuses the new service it puts back the file that was there and starts that one again (`ServiceRefused` says which). ([1240735](https://github.com/spacesheep-dev/sessionpipe/commit/1240735))

## 0.14.2 (2026-11-12)

- **patch** outbox: a line that is not valid UTF-8 no longer costs a later event
  
  `Outbox.read` counted each line's length from its decoded text. Bytes that are not UTF-8, such as the end of an append cut off inside a character with the next append joined onto it, decode to U+FFFD, which is three bytes and can stand for one to three bad bytes, so the offset it returned could run a few bytes past the line's end. The next read then started inside a line, which failed to parse and was skipped without a word: the drain, `sessionpipe tail` and the GUI's outbox source all missed that event. Lines are now found and counted in the bytes as read, so the offset is always the byte after the newline. ([554f990](https://github.com/spacesheep-dev/sessionpipe/commit/554f990))

## 0.14.1 (2026-11-12)

- **patch** pool: a request over the body cap gets the 413 that says why, not a connection reset
  
  The pool answered a request over its 32 MiB cap the moment the cap was passed, while the client was still sending, and closed the connection. On macOS and Windows the bytes still on their way could make the connection reset, throwing away the 413 before the client read it, so Claude Code saw a connection error instead of "request body over … bytes". The pool now reads the rest of the body and drops it (nothing past the cap is kept), and answers when the request ends. A body more than 8 MiB past the cap, or one still arriving 5 seconds after it passed the cap, is answered then and the connection closed, so an endless or stalled upload holds nothing; that far out a reset can still happen. ([44349eb](https://github.com/spacesheep-dev/sessionpipe/commit/44349eb))

## 0.14.0 (2026-11-12)

- **minor** Grok Build: sessions report under their own name, `sessionpipe install --grok-build`
  
  - A new adapter, `grok-build`, for xAI's Grok Build CLI. `sessionpipe install` writes a hooks file of its own, `~/.grok/hooks/sessionpipe.json`, and reports the session, its turns and tools, a permission prompt that is waiting, and (at tier 2) the conversation from the session's `updates.jsonl`. Title, model, folder and times come from the session's `summary.json`. `backfill` lists past sessions.
  - Grok Build also runs the hooks in `~/.claude/settings.json`, with a payload that looks like Claude Code's. Until now sessionpipe's Claude Code entry there reported every Grok session as a Claude Code session. That entry now tells a Grok payload apart and reports nothing; the Grok adapter reports it.
  - Recorded on Grok Build 1.0.77; `PostToolUseFailure`, `StopFailure` and `PreCompact` are mapped from Grok's documentation and still wait for a recorded payload. ([052c0c9](https://github.com/spacesheep-dev/sessionpipe/commit/052c0c9))
- **patch** ask: a level or a model on a new ACP session is refused, and `ask status` lists ACP agents
  
  - `sessionpipe ask grok-build "…" --permission read` started the agent with the flag
    dropped: an ACP agent takes no level (CONTROL.md §6) and ran as the machine's mode
    said, so on an auto machine a "read" ask could write. It is refused now, in words
    that name the mode, as an ask into an existing ACP session already was. `--model`
    the same.
  - `sessionpipe ask status` listed only the agents that take start options, so an
    installed ACP agent (Grok Build, Gemini CLI, …) the daemon could start was missing.
    It lists what the daemon says an ask can start. ([3173ef3](https://github.com/spacesheep-dev/sessionpipe/commit/3173ef3))

## 0.13.8 (2026-11-11)

- **patch** A poll that never ends no longer holds the control daemon for ever, unseen. The daemon ends a request it has held too long by aborting it, and then waited for the abort to take effect; where it took none, it waited without limit. Seen on a laptop on Node 23.6.1, a second after its clock was set forward thirteen minutes: one poll held for 103 minutes with no connection left and no error, `sessionpipe control status` reading "polling: yes" while the receiver showed the machine offline, until the daemon was restarted. Why the request never ended is not known. Now, ten seconds after its abort a poll is left behind and asked again on a new request; the log says so ("a poll never ended, and aborting it ended nothing; asking again") and `sessionpipe doctor` shows it as the receiver's last error. Three in a row and the daemon restarts itself when nothing is in hand, the one remedy known to work, at most once an hour; until then that receiver is asked every ten minutes. A hello, an ack and the look at a receiver's well-known are left behind the same way: an ack that never ended kept every later ack from being sent, and the daemon from ever being idle enough to update. ([ae93ff7](https://github.com/spacesheep-dev/sessionpipe/commit/ae93ff7))

## 0.13.7 (2026-11-11)

- **patch** Pairing a Mac again no longer leaves it with no daemon. Installing the service over one that is running (`sessionpipe control pair` on a machine that is already paired, `sessionpipe ask on`) booted the old one out and bootstrapped the new one at once; launchd answers a bootout before the old process has exited and refuses a bootstrap until it has, so the daemon was stopped and nothing started it again: the machine read offline until someone ran `launchctl bootstrap` by hand. The install now waits for launchd to let go of the old service (up to 10 s) and then starts the new one, and when launchd still refuses, the error says what launchctl answered and the command that starts it. On a Mac this happened to, pair again or run `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.sessionpipe.control.plist`. ([0c3702a](https://github.com/spacesheep-dev/sessionpipe/commit/0c3702a))

## 0.13.6 (2026-11-10)

- **patch** The `PermissionRequest` hook names a prompt by its tool call's id where Claude Code's payload carries one, as the `attention.needed` it reports already did. The hook always used the hash of the prompt, the tool and its input, so on a Claude Code that names the tool call the page's Allow / Deny would have named a prompt the daemon did not hold. None does to date: nothing changes on the versions recorded so far. ([310ba20](https://github.com/spacesheep-dev/sessionpipe/commit/310ba20))
- **patch** A permission question can be answered from the page while a subagent is running. The daemon holds a `PermissionRequest` hook so that the page's answer can be handed to it, and let go of it at the session's next tool result, Stop or new prompt, taking that to mean the terminal had answered. A session's hooks all report under its id, a subagent's among them: with a subagent reading files in the background, the main thread's question was let go at the subagent's next tool call, a second or two after it was asked and with the terminal still asking, and the answer was refused `no_attention`. A held question is now let go only by an event that says that question is over: a tool result of the thread that asked (or of its own tool call), a Stop or a new prompt where the main thread asked, the subagent's own stop (or a Stop with no background work left) where a subagent asked, and the session's end. A tool result, a Stop or a new prompt from another thread lets go of nothing, and neither does an event whose hook fired before the question, however late it is reported. The hook and the worker tell the daemon which thread and tool call each is about; a hook older than this talking to a newer daemon, or a newer hook to an older daemon, behaves as before. Two limits remain. A thread that runs several tool calls side by side still ends its own question at a sibling call's result, with the terminal still asking. And a question that is over with no event to say so (the terminal refused it and only other threads report afterwards, or a subagent asked and the turn was interrupted) stays held until the hook's 125 seconds are up: an answer in that time is acked `delivered` and changes nothing. ([85ef4ae](https://github.com/spacesheep-dev/sessionpipe/commit/85ef4ae))

## 0.13.5 (2026-11-10)

- **patch** A message sent in the first moments of a turn typed in an open Claude Code window is no longer answered in a copy. The window's registry row says it is busy a few hundred milliseconds before its hooks' report of the turn reaches the daemon, and for a moment after they report the turn's end: a message that landed in between found a window that was busy (so not written into its inbox) and not mid-turn (so not handed over at its Stop), and ran in a copy. Every later message for that session then went to the copy, "ran in the copy made earlier", while the window sat idle. Where a window is busy by its row and its hooks have not said so yet, the daemon now waits up to two seconds for them (the message is acked `taken` meanwhile) and routes it again: it is handed over at that turn's Stop, or written into the inbox of a window that is idle after all. A session whose hooks have never reported to the daemon is not waited for. This applies where the daemon can read the window's own inbox at all: on Windows, and for a window that offers no inbox, such a message is still answered in a copy, as before. ([967e639](https://github.com/spacesheep-dev/sessionpipe/commit/967e639))
- **patch** A run that was asked for a copy and ran as the original instead no longer frees the original when it ends. A Claude Code that takes no notice of `--fork-session` runs in the session it was told to copy, and its hooks report under that session's id: when such a run ended by itself, its own Stop and SessionEnd read as the original's, the write that had held the original counted as accounted for, and the next message was resumed in place. After a copy's run that is not known to have run in another session, the original now stays held for 90 seconds, as it is by any write of unknown hand, so the next message is copied (or waits) instead. ([551bf27](https://github.com/spacesheep-dev/sessionpipe/commit/551bf27))
- **patch** An ACP agent that goes away with a message to it still unwritten no longer raises an error nobody catches. A message longer than a pipe holds waits in the pipe; when the agent then exits or is closed, the write fails afterwards, on the agent's input stream, which had no error listener: an uncaught `write EPIPE` in the daemon. The failed write is now heard like the agent's exit is: whoever is waiting for the answer gets "<agent> stopped taking input: …" (or its exit), and the session is closed so the next message opens another. ([af298b3](https://github.com/spacesheep-dev/sessionpipe/commit/af298b3))
- **patch** A turn that was stopped no longer reads as answered with "No response requested."
  
  When a Claude Code session is resumed after its last turn was ended mid-way (a Stop sent
  through `sessionpipe control`, a killed process, a crash), Claude Code writes "No response
  requested." into the transcript as the assistant's, under its own `<synthetic>` model.
  sessionpipe sent that on as what the agent had said, so a receiver drew a stopped turn as
  answered with those words. It is now read as the end of a turn that was stopped, the way
  `[Request interrupted by user]` is: the turn goes out with what it had really said, which
  may be nothing. Claude Code's other synthetic rows, such as an API error, are still the
  turn's reply. ([54736f5](https://github.com/spacesheep-dev/sessionpipe/commit/54736f5))
- **patch** A Claude Code session is found only under the id its transcript is filed as. On a disk that ignores case (macOS, Windows) a command naming `claude-code:8C132906-…` opened the transcript of `8c132906-…`, while the session's registry row and its hooks, which are what say it is open in a window or mid-turn, are kept under the id as Claude Code wrote it: the session looked idle and was resumed in place, `claude -p --resume 8C132906-…`, beside the person's own window. Such a command is now refused `no_session` ("no Claude Code session 8C132906 on this machine") on every platform, as it already was on Linux. A new session's id that differs from an existing session's only by case is still refused as taken (`bad_session`): on such a disk it would be written into that session's file. ([4d1a18e](https://github.com/spacesheep-dev/sessionpipe/commit/4d1a18e))
- **patch** A Stop for a session of one harness no longer stops another harness's session that has the same id. The daemon looked up the turn it was running, and the session it was holding, by the id alone: a Stop for `claude-code:<id>` ended its run of `codex:<id>`, and a Stop for `codex:<id>` interrupted a Claude Code session it held under that id. Both are found by the whole session ref now. ([3407fc3](https://github.com/spacesheep-dev/sessionpipe/commit/3407fc3))
- **patch** A Stop that arrives together with the turn it was pressed for stops it. The daemon takes the messages of one poll side by side, and a Stop has the least to check: where it was through its checks before the new session or the message it was sent after, it found nothing in hand and said so ("no Claude Code session … on this machine" for a start, "nothing is running in that session…" for a message), and the turn then began. With the two in one poll that was one or two starts in twenty-five, and every time the poll listed the Stop first. A Stop now waits the moment it takes for a start or a message for its session that has already arrived, and was signed no later than the Stop, to be in hand, and stops it. It does not wait for anything signed after it, nor for a message or a start that is itself waiting behind another turn. ([edb4f09](https://github.com/spacesheep-dev/sessionpipe/commit/edb4f09))
- **patch** A message for a session whose copy is in the middle of a turn is no longer run into that turn. A session's later messages go to its copy, and the copy's hooks report under the copy's own id, but only the original's own mid-turn state was looked at: with a turn running in the copy and no registry row to say so, a message that named the original was `delivered mode=resume` in the copy, beside the process that had it. A turn the copy's own hooks report now holds the copy, and the message is refused: `unsupported`, "another Claude Code process on this computer was running the copy this session's messages go to, and a copy is never itself copied: send it again once that turn has ended (a turn that was cut short stops counting ten minutes after it was last heard from)". That ack is final. Nothing is delivered when the copy's turn ends; the message has to be sent again, and is then resumed in the copy. Where the process in the copy died without its hooks saying so, the refusal goes on for up to ten minutes after its last hook event (0.13.4 resumed the copy 90 seconds after its last write); a run of the daemon's own that ends, however it ends, lifts it at once. ([fbe5ee5](https://github.com/spacesheep-dev/sessionpipe/commit/fbe5ee5))
- **patch** A session that a window has open is no longer resumed in place because a run of the daemon's own was in the way when the message was routed. While the daemon runs a turn in a session, the next message for it is not copied: it waits for that run and resumes the session afterwards. Whether it may be resumed was decided when the message arrived and never looked at again, so a message that waited was run with `claude -p --resume` beside a window that had opened the session meanwhile; a run that was only copying a session counted as a run in it; and a Codex session the daemon started counted as a run in the Claude Code session of the same id. The daemon now looks again when the waiting message's turn comes, and if something holds the session by then the message is routed afresh: into the window's own inbox, into a copy, or acked `unsupported` with the reason. A copy's run no longer stands for a run in the session it copies, and the daemon's record of its own runs is kept per harness. ([afeaa7d](https://github.com/spacesheep-dev/sessionpipe/commit/afeaa7d))

## 0.13.4 (2026-11-10)

- **patch** A copy that is stopped before it has written anything is not recorded as the session's copy. A fork names its new session in its first line of output, before there is a transcript behind the name, so a copy counts as the session's only once its transcript is on disk. Stopped before that, the turn's ack names no copy, and the next message is routed afresh instead of being sent to resume a session that does not exist. ([17968a2](https://github.com/spacesheep-dev/sessionpipe/commit/17968a2))
- **patch** A copy of a session that fails no longer makes the machine treat the original as free. The end of the daemon's own run counted as the end of whatever had last written to the session it was started from, also when that run was a copy (`--fork-session`), which runs in the copy and never in the original: so after a copy failed, a message sent a second later was resumed in place, in a session that something else had written to seconds before. A copy's run now accounts only for the copy, however it ends (by itself, by failing, by a Stop): the original stays held for as long as it was going to be, and a turn the person is in there is still a turn in progress, so the next message is copied again or waits for their Stop. ([2ee7f71](https://github.com/spacesheep-dev/sessionpipe/commit/2ee7f71))
- **patch** A message to a session whose headless run has just exited now resumes it in place at once. A transcript written in the last 90 seconds used to count as a session in use whoever wrote it, so a message sent 7, 9 or 39 seconds after a `claude -p` at a terminal had exited ran in a copy, and the reply said the session was open in a window. The session's own end, as its hooks report it (`SessionEnd`), now accounts for that write the way the end of the daemon's own run already did; a write nothing accounts for still makes a copy, since a writer may be behind it. Where a copy is made the ack says why, as the machine found it: `detail` opens "ran in a copy, because" and then "the session was open in Claude Code on this computer (a terminal window or the Claude app)", "another Claude Code process on this computer was running the session", or "the transcript of the session had been written to in the last 90 seconds by a process this computer could not find", followed where it applies by "; it could not be reached in place: …" with what the session's own inbox said. A later message that resumes the copy says "ran in the copy made earlier, because …". The copy itself is told "the original is open in a window" only when it is. The refusals for a held session that can't be copied say the same three things instead of "the session is open". ([2e80bf1](https://github.com/spacesheep-dev/sessionpipe/commit/2e80bf1))
- **patch** A Stop finds the turn under either of a session's two names. A session whose messages go to a copy is on the board twice, under its own id and under the copy's, and the fork's ack and the copy's own events send a person to the copy. A Stop pressed on the copy stops the turn the daemon is running for a message sent to the session, also while that turn is still being prepared, and while the copy is still being made once it has named itself; a Stop pressed on the session stops a turn running in its copy for a message that was sent to the copy. Where a message is waiting behind the turn that is running, the running one is what is stopped, and the one that waited then runs. The daemon's own child in a copy is never described as "a Claude Code process this computer's daemon didn't start". ([f127a21](https://github.com/spacesheep-dev/sessionpipe/commit/f127a21))
- **patch** A turn stopped while its files are still being downloaded gets one final ack, and the download ends there: the request in flight is dropped, no further file is asked for, and a file that arrives all the same is not written into the folder. A message stopped then is acked `delivered` with "(The turn was cancelled.)", and a new session `failed` with code `cancelled` and nothing of it left in the project. ([54580af](https://github.com/spacesheep-dev/sessionpipe/commit/54580af))
- **patch** A session whose run the daemon ended is no longer left mid-turn. The child's own hooks mark the session mid-turn when its turn begins, and a child that is killed reports no Stop: after a run ended at the 30-minute cap the next message for that session waited for a Stop that was never coming, up to 30 minutes. When a run of the daemon's own ends, however it ends (by itself, at the cap, by a Stop), the session it ran in is no longer mid-turn, and whatever was waiting for its Stop is delivered another way. So after a Stop the next message runs at once, and a second Stop answers "nothing is running in that session as far as this computer can tell, so there was nothing to stop". ([ca925d5](https://github.com/spacesheep-dev/sessionpipe/commit/ca925d5))
- **patch** Nothing of a message's `id` reaches a session unless it is a whole ULID. The id is the receiver's and nobody signs it, and the last twelve characters of whatever it was went into the text written into a live session's inbox, inside the header that says "signed on the sender's device". This release puts that mark on every message handed to a live session, and makes it only from a 26-character ULID: `messageMark` in `sessionpipe-core/control` answers null for anything else, and `frameMarked` then frames the message without one. A message whose id is not a ULID is still delivered, as far as it can be without a mark. It is not handed to a session's `sessionpipe channel` and not written into its inbox, the two ways in whose only proof of delivery is the mark, so it takes the next rung instead: the session's Stop where a turn is running, else a resume or a copy (a copy's ack says "it could not be reached in place: the receiver gave this message an id that is not a ULID, so the turn it would cause there could not be told from any other"). Handed over at the session's Stop or to `sessionpipe wait`, it is acked `delivered` with no `reply` when the session's turn ends. Receivers that follow HTTP.md already send ULIDs and see no change. ([8fb6b5d](https://github.com/spacesheep-dev/sessionpipe/commit/8fb6b5d))
- **patch** When the daemon can't read a Claude Code's options, the refusal says why in the command's own words: `claude --help` failed: env: node: No such file or directory (the first line it wrote to stderr), "no such file", "the file can't be run (permission denied)", or its exit code. It used to say only "failed", which gave the person who sent the message nothing to act on. The line is one line of at most 200 characters, with the home folder written `~` and anything shaped like a secret removed. ([04d615f](https://github.com/spacesheep-dev/sessionpipe/commit/04d615f))
- **patch** Closing an ACP agent that has already exited signals nothing. The daemon closes a held agent it finds gone, and the close armed its two timers all the same (SIGTERM after 2 seconds, SIGKILL after 12), whose only disarming was an exit that had already happened: both went to the number of a process group that no longer existed, or that the system had since given to another process. ([d41c1ff](https://github.com/spacesheep-dev/sessionpipe/commit/d41c1ff))
- **patch** A headless run that reaches its 30-minute cap no longer signals its process group ten seconds after the child has exited. The follow-up SIGKILL was on a timer that was never cleared, so it went to the group's number even when the group was gone: the one way the daemon could signal a process it had not started (a pid reused as a group leader inside those ten seconds). The timer is cleared when the child exits, whatever the child left in its group (a tool that ignores SIGTERM) is killed at that moment, once, and a cap that arrives after the child has exited signals nothing and no longer marks the finished run as timed out. A run ended by a Stop, which this release adds, is ended the same way. ([489494e](https://github.com/spacesheep-dev/sessionpipe/commit/489494e))
- **patch** The control daemon builds the words around a delivered message with the same definitions the transcript readers take them off with (`sessionpipe-core/control`), so the three wordings this release adds are left out of a turn's `user` text like the rest: the header that names the message where it is handed to a live session at its Stop or through its waiter (`Message via <host> (sessionpipe control, signed on the sender's device; message XXXXXXXXXXXX):`), the note to a copy whose original is not open in a window but in use by another process, and the mark in an ask handed over in place (`…with sessionpipe ask, message XXXXXXXXXXXX. Answer here in full: …`). The paragraph that says an agent asked stays, in its plain form (`(Asked by <harness>:<id> on this machine with sessionpipe ask. Answer here in full: …)`), whichever way the task arrived. `sessionpipe-core/control` exports `messageMark` (null for an id that is not a ULID), `frameMarked`, `copyNote(why)` and `unframe`, and `frameAsk` takes the mark as a third argument. ([d9def02](https://github.com/spacesheep-dev/sessionpipe/commit/d9def02))
- **patch** Two messages can no longer run in one copy's transcript at the same time. A session's messages go to its copy, and the copy is on the board under its own id, so a person can message either. The daemon queued runs under the id each message named, so one sent to the session and one sent to the copy were started side by side in the copy whenever the copy's own hooks had not yet marked it mid-turn (always, where that account has no hooks). Runs are queued by the transcript they run in. ([4772e34](https://github.com/spacesheep-dev/sessionpipe/commit/4772e34))
- **patch** Messages for one session run one at a time in the order they were sent. Three messages sent half a second apart behind a running turn ran first, third, second: the two that arrived in one poll were verified side by side, and the one whose verification finished first went first. A message now takes its place in its session's line as it arrives, before it is verified, and the line goes by the time each message was signed (`iat`), then by arrival. That holds on the resume, fork and turn rungs, for a message that has to wait for its session's start, and when the message ahead is stopped, fails or is refused. ([6231d19](https://github.com/spacesheep-dev/sessionpipe/commit/6231d19))
- **patch** A refusal for a session this machine doesn't have names it by the start of its own id, the one a receiver's page and `claude --resume` use: "no Claude Code session 88749ed7 on this machine". It used the protocol's short id, a hash no page shows ("66a04e0a" for that session), so the person was told about a session they had never seen. The same for Codex sessions and for the daemon's own log lines. A machine whose start limit is 0 now says "this machine is set to start no new sessions (the start limit in its control config is 0)" instead of "already starting 0 new session(s); it starts at most 0 at once". A message rerouted at a Stop to a session whose transcript has gone is refused with that reason, not with a bare code. And two sessions that each have a copy are no longer taken for one session by `sessionpipe ask`'s loop rule, which refused an ask between them as a session asking itself. ([2a06d7e](https://github.com/spacesheep-dev/sessionpipe/commit/2a06d7e))
- **patch** The reply to a message delivered in place (at a session's Stop, to its waiter, into its inbox) is now the answer to that message, or nothing. Three things went wrong on one day: a message handed over at a Stop came back with the sentence the session had written before the message arrived (Claude Code writes its transcript a moment after the fact, so the turn's last words landed after its Stop hook had asked); a message to a session that also had a copy was read from the copy's transcript, so its ack waited 215 seconds and carried a later message's answer; and another such message was acked 30 minutes late with no reply at all. The message now names itself in its frame, by the last twelve characters of its id (`…signed on the sender's device; message XXXXXXXXXXXX):`, and for an ask `…with sessionpipe ask, message XXXXXXXXXXXX.`), the reply is what the session wrote after the rows its transcript records for that message and before anything else begins (the next message, a prompt the person types, a turn a background task starts), it is read from the transcript of the session the message was handed to, and the final ack goes out when the session's own hooks say that turn ended. Where no answer can be put down to the message the ack is `delivered` without a `reply`, and no longer carries a file path in `detail` when the transcript can't be read. ([ce3f0ab](https://github.com/spacesheep-dev/sessionpipe/commit/ce3f0ab))
- **patch** A Stop reaches a turn from the moment its message's turn comes, not only once its process is running. Before a child process exists the daemon checks the harness's login (`claude auth status`, seconds when it is not cached), downloads the message's files and may wait for a free slot: a Stop in any of those means the turn never starts. The Stop is acked `taken` and then `delivered` ("stopped the turn this computer was running for that session"), a message's own ack is `delivered` with "(The turn was cancelled.)", and a start that never began is acked `failed` with code `cancelled` and "stopped before it started, as asked: no session was created". For a session whose start is still running, a Stop stops that start and not a message waiting for it, whichever of the two the daemon got through its checks first, and it never waits for a start to end. ([eece6aa](https://github.com/spacesheep-dev/sessionpipe/commit/eece6aa))
- **patch** Stop (`cancel`) now stops a turn the daemon itself is running. A session started or resumed from a page runs as a `claude -p` (or `codex exec`, `agy`) child of the daemon, and a Stop for it used to be acked `unsupported` while the turn ran to its end. The daemon now ends that child the way it ends one at the 30-minute cap (SIGTERM to its process group, then SIGKILL to whatever is left, ten seconds later or as the child exits), acks the cancel `taken` and then `delivered` with the turn's mode and "stopped the turn this computer was running for that session", and acks the stopped turn's own message `delivered` with what it had written, ending "(The turn was cancelled.)". A message queued behind the stopped turn still runs next, in order. A turn in a process the daemon did not start is never signalled: the cancel is `unsupported` and says who has the session ("…open in Claude Code on this computer (a terminal window or the Claude app)… press Esc there", or the pid of someone else's headless run), and a session nothing is running in is `expired` ("nothing is running in that session as far as this computer can tell, so there was nothing to stop"). spec/CONTROL.md §6 `cancel` says so, with fixtures `conformance/delivery/30`–`33`. ([26eec6c](https://github.com/spacesheep-dev/sessionpipe/commit/26eec6c))
- **patch** What 0.13.0 to 0.13.2 told a copy ("You are a copy of that session, started to answer this message: …") is taken off a turn's `user` text like the notes written today. No daemon says that sentence any more, but every copy those versions made has it in its transcript after the person's words, where a backfill reads it. Where another agent's task went to such a copy, the paragraph that says who asked is the last of the turn's `user` text, as for any other ask. ([4d9654f](https://github.com/spacesheep-dev/sessionpipe/commit/4d9654f))
- **patch** A turn in a Claude Code terminal session reaches a tier-2 sink when it ends, not when the next one does. Claude Code writes its transcript up to 100 ms after the fact and a terminal's Stop hook fires sooner than that, so the worker used to read a file that did not hold the turn's last message yet: the receiver got "the turn ended" and then the prompt and the reply one turn late, or only when the session closed. The Stop names the turn's last words, and the worker now waits for the file to hold them: 400 ms with the turn's end, then for 5 s more behind it, then at the session's next hook. A turn is sent once, and whole: one that said something before a tool call is no longer sent as that half with the rest as a second turn that nobody asked. The hook itself does no more than before.
  
  A turn stopped with Esc or Ctrl-C is no longer lost. No hook fires for it, so at the session's next prompt, compaction, start or end the worker reads the interruption from the transcript and sends the turn with what it had said, and a `turn.ended` with `reason: interrupt` where the turn was still held as running. The next prompt is a turn of its own instead of being joined to the one that was stopped, and `[Request interrupted by user]` is never part of anyone's prompt. ([b6ed157](https://github.com/spacesheep-dev/sessionpipe/commit/b6ed157))
- **patch** A turn's `user` text is what the person wrote, and nothing the machine wrote around it. A receiver draws that text as the person's own message, and three things were getting into it from Claude Code sessions. A subagent's report: when a subagent hands its report back, Claude Code records it the way it records a message from another session, and it was read as part of the prompt ("[Subagent hand-back] The text below is the final report of a subagent…"). sessionpipe's own words around a message it delivers: "(Sent via <host> with sessionpipe control, signed on the sender's device.)", "Message via <host> (sessionpipe control, …):", the note to a copy of a session ("You are a copy of that session, started because the original is open in a window…"), and the notes on how an answer travels. And `[Request interrupted by user]`. All are left out now, in `turn.transcript`, in `turn.progress` from its first moment, and in the title a session takes from its first ask; the list of attached files stays. sessionpipe's sentences come off only where the daemon puts them (the line before the words, the line or the note after them): the same words typed or pasted by the person stay, and a prompt that is nothing but one of them is sent as typed.
  
  Two of sessionpipe's own lines stay where they were, because a receiver tells an agent's words from the person's by them: the paragraph after a task another agent asked with `sessionpipe ask` ("(Asked by <harness>:<id> on this machine with sessionpipe ask. …)", the last paragraph of the turn's `user` text however the task arrived), and the first line of a handoff's answer ("Answer from <harness>:<id> (sessionpipe handoff, …):").
  
  A message delivered into a terminal session at a Stop hook is now a turn of its own: its `user` text is the message, marked `injected` like any message that arrived from elsewhere, and the reply is that turn's. Its reply used to be added to the turn before, or sent as a turn with no prompt.
  
  `sessionpipe-core/control` exports the sentences (`frame`, `frameStart`, `frameMarked`, `frameAsk`, `frameHandoff`, `IN_PLACE_ANSWER`, `copyNote`, `RE_ARM`) and `deliveredWords`, which takes off a text the ones that are not the message. ([59d0716](https://github.com/spacesheep-dev/sessionpipe/commit/59d0716))
- **patch** The protocol's JSON Schemas (`schemas/v1/*.json`) are generated from `proto/sessionpipe/v1/*.proto`, the protocol's one definition (RFC 0001, step 1), byte-identical to what the Zod source produced. The Zod file stays this release and is held equal to the proto by a test; nothing on the wire changes. ([d8523d5](https://github.com/spacesheep-dev/sessionpipe/commit/d8523d5))

## 0.13.3 (2026-11-05)

- **patch** A `claude` installed under a node version manager is found by the daemon. launchd and systemd start the daemon with a bare PATH, so a Claude Code that `npm install -g` had put under nvm was never found; with no binary, its capabilities read as none, and a message to an open session was refused with "this Claude Code can't copy it (no --fork-session)" for a Claude Code that had the flag. The lookup now also checks nvm, fnm, volta and asdf (their default folders, or the ones NVM_DIR, FNM_DIR, VOLTA_HOME and ASDF_DATA_DIR name), newest node first. `sessionpipe connect` writes the pairing shell's PATH and the `claude` it found (as SESSIONPIPE_CLAUDE) into the service file, so the daemon sees what the person's shell saw. When no `claude` can be found at all, the daemon says "Claude Code not found on this computer" and what to do, in its log at start and in the failed ack, instead of blaming the flag. ([646042a](https://github.com/spacesheep-dev/sessionpipe/commit/646042a))
- **patch** A copy of an open session does what the message asks. Its note used to say "nothing you do here appears… don't carry on the original's work", so a copy asked to change a page answered that it couldn't publish. ([a0de39d](https://github.com/spacesheep-dev/sessionpipe/commit/a0de39d))
- **patch** A machine's hello says, per Claude Code account, whether the command line is signed in to it (`accounts[].signed_in`, CONTROL.md §2), from the same `claude auth status` the daemon asks before a resume. A session signed in through the Claude app runs fine in its window while the `claude` the daemon resumes with has no login of its own, and until now the first message found that out, failing with "run `claude auth login`"; a receiver can now say so before anyone sends one. The daemon logs the change ("claude isn't signed in as me@example.com as the daemon sees it …"), once per change. Absent when the daemon can't tell. ([0d718db](https://github.com/spacesheep-dev/sessionpipe/commit/0d718db))

## 0.13.2 (2026-11-03)

- **patch** A subagent's end now carries the `background` count it restates (PROTOCOL.md §4, #153). 0.12.0 added `background` to `subagent.ended` so a receiver holding a session as waiting on its own work learns the count fell before the session wakes, but the privacy allowlist still kept only `agent_id` and `agent_type` for that event, so the count never left the machine. `turn.ended.background` was unaffected. ([bf55b04](https://github.com/spacesheep-dev/sessionpipe/commit/bf55b04))

## 0.13.1 (2026-11-03)

- **patch** A config.json that does not parse (a hand edit's trailing comma, a byte-order mark, a file cut short) is no longer written over with the defaults, which erased the machine's name and every sink with its token. A command that would save it stops with an error naming the file and what is wrong with it; `install`, `connect`, `uninstall` and `control pair` stop before they change anything, and `sink add` before it asks the receiver anything. A config.json that is there but cannot be read is not written over either. ([5892379](https://github.com/spacesheep-dev/sessionpipe/commit/5892379))
- **patch** A machine running workflows with hundreds of parallel subagents no longer runs a receiver out of requests (#208). A receiver is now sent a subagent's finished tool calls (`tool.ended`) at most one per session per minute, plus the one that shows a subagent's permission prompt was answered, the main thread's tool events as they happen, and at most one request a second from the machine, carrying everything that piled up meanwhile. Turns, prompts, permission questions, subagents and file changes are never thinned, and the local outbox still keeps every event. `sessionpipe sink add … --tools all|main|beat` picks what a sink is sent: `all` (every tool call, as before) is the default; a receiver that meters requests asks for `main` with `tools` in its well-known file, which the machine reads at `sink add` and `connect` and again once a day; `beat` thins the main thread too. No receiver's view changes on this upgrade unless it asked. `sink list`, `status` and `doctor` show each sink's setting; `replay` says how many tool events it thinned and takes `--tools all` to send every one once. A permission prompt now carries `agent_id` when a subagent asked, and a Codex subagent's tool calls carry one too. A worker near the end of its 15 s life no longer starts a drain it can't finish, and hands the work to a fresh worker instead; two workers can no longer both take over the same stale lock. In a simulated workflow of 100 subagents making 30 tool calls each over three minutes (6,214 events), a receiver got up to 6,214 requests before (one per event, with the jobs run one after another; real concurrent workers merged some) and 76 with the default; a replay of the issue's real events put the same rules at about 9,900 requests a day, against 39,500 with the one-a-second pace alone. ([439d8a6](https://github.com/spacesheep-dev/sessionpipe/commit/439d8a6))
- **patch** `sessionpipe forget` takes the session out of every sink's cursor along with its outbox file, so a session that goes on after it (still running, or resumed later) is sent from its new file's first event. Before, each cursor kept the old file's end offset, and the sinks read nothing from the new file until it grew past that offset, then started mid-way, so the first events were never sent. The cursors are the drain's, so `forget` and `replay` wait for its lock (up to 10 s) before they change anything; past that they change nothing, say so and exit 1. A cursor file holding `null` or a list reads as empty instead of stopping a drain. ([2f7f39c](https://github.com/spacesheep-dev/sessionpipe/commit/2f7f39c))
- **patch** `sessionpipe install`, `uninstall` and `backfill` refuse a bare word (`uninstall codex`, `uninstall --keep-state codex`, `install claude-code`), say to pass `--<harness>`, and exit 2 without touching anything. The word was ignored, so `uninstall codex` unhooked every harness and `install claude-code` hooked every harness found. A word that is an option's value (`install --machine codex`) is read as before. ([60528c6](https://github.com/spacesheep-dev/sessionpipe/commit/60528c6))
- **patch** `sessionpipe install` exits 1 when every harness it worked on was left alone (a settings file that isn't plain JSON, say), and its last line says nothing was hooked. It exited 0, so a script couldn't tell. One harness hooked is still exit 0. ([2cd2845](https://github.com/spacesheep-dev/sessionpipe/commit/2cd2845))
- **patch** A harness config file the system won't let sessionpipe write (read-only: EACCES, or EPERM on Windows after `attrib +r`; a read-only filesystem's EROFS) is reported for that one file with the code — `! claude-code: ~/.claude/settings.json — can't write it (EACCES); not touched` — the way a file that is not plain JSON already was, and `install` goes on to the next harness, so Codex and Gemini CLI are still hooked. Before, the first such file ended the whole command with `EACCES: permission denied` and left every harness after it unhooked. The refusal is caught where each file is written, so a file the adapter wrote before it (Codex's hooks.json before config.toml, one Claude Code account's settings.json before another's) keeps its report, `created` included, which `uninstall` needs. `uninstall` reports a file it can't write or delete the same way and goes on. Only a write that fails after the file was opened (ENOSPC) says the file may be cut short instead of "not touched". ([a0197d5](https://github.com/spacesheep-dev/sessionpipe/commit/a0197d5))
- **patch** A drain skips an outbox file it can't read (a 0600 file a hook left as another user under the same HOME, or, on macOS and Linux, a folder named `<id>.jsonl`; Windows reads a folder as an empty file) with a line in the log naming the file and the error, leaves that session's cursors where they were, and still sends every other session to every sink. Before, the read error threw out of the drain: no sink took the sessions listed after that file, nothing was logged, and the prune that follows the drain never ran. ([6456357](https://github.com/spacesheep-dev/sessionpipe/commit/6456357))
- **patch** pii@1 keeps an ISO date: `released 2026-10-31` stays as written instead of becoming `released [phone]`, and `2026-10-31 12:00` no longer becomes `[phone]:00`. A date (years 1900–2099, month 01–12, day 01–31) is kept only when nothing after it could carry the run on: no letter or digit, and no separator then two digits (a phone's group; `2026-10-31 5 files` keeps its date) unless that is a time's hour (` 12:00`, 00–23), and not right after a `)` or a `+` with a country code. A date after a `+` (a diff's added line) is kept too. A date that a phone's digit group follows directly, as in `2026-10-31 415-555-0132` or `2026-10-31 0132:`, is read as before (a `(`, `+` or `,` between them starts a new number, and the date stays). ([f24d3c2](https://github.com/spacesheep-dev/sessionpipe/commit/f24d3c2))
- **patch** A sink added with `--secret` and no `--token` (it signs with Standard Webhooks and sends no bearer) sends again once its secret is in the macOS Keychain or the Secret Service, which `connect` does by itself on a Mac: the secret was stored, but reading it back gave up without a token, so every drain treated the sink as locked and its events waited for good, `sessionpipe secrets` said it couldn't be read, and `secrets move file` left it there. Each item the store holds is read now, so a sink an older release left stuck comes back the same way, and `secrets move file` brings it home. A sink with both items whose token item alone is gone sends signed and without a bearer, and the receiver's 401 pauses it (`doctor` says so) instead of its events waiting silently; a locked store still leaves them waiting. `sink add` with `--token` on such a sink keeps the secret its store holds, instead of dropping it. ([a66c937](https://github.com/spacesheep-dev/sessionpipe/commit/a66c937))
- **patch** A batch the receiver answers 413 is halved, and when one half then gets a 400, only that half is dropped (and logged with its size); the other half is still sent. Before, the 400 on the first half was taken as the answer for the whole batch: the cursor moved past both halves, so the second half's events were never sent, and the log named only the 400. ([f954c5e](https://github.com/spacesheep-dev/sessionpipe/commit/f954c5e))
- **patch** `--tier` takes a whole number: `sink add`, `install --sink`, `connect`, `control pair` and `tail` refuse `--tier` with its value left off, empty, or not written in digits (`--tier one`, `--tier 1.5`, `--tier 0x2`, `--tier --name x`), with "--tier takes 0, 1, 2 or 3", before doing anything. The other `sink`, `install` and `control` subcommands refuse such a `--tier` too, where they ignored it. `connect --tier ""` used to fall back to the default tier and `sink add --tier ""` stored 0; both are refused now. Until now `sink add` stored `--tier one` as `null` (and `--tier 1.5` as 1.5, which no event can carry) and printed "tier NaN sends: undefined", and stored `--tier` with its value left off as tier 0 without a word. A value out of range is still held to 0–3, and a tier of 1.5 already in config.json sends as 1. The hook reads a sink's tier the same way, capped by the receiver's `max_tier` too: a stored 0.5, or a sink a receiver caps at 0, no longer turns every tool event, or every status line's limits, into a job. A sink with no tier in config.json (a hand edit) sends at tier 0, where its events carried `tier: null`, which no receiver takes. ([ed73aa5](https://github.com/spacesheep-dev/sessionpipe/commit/ed73aa5))
- **patch** `sessionpipe update` with a word other than `off` or `on` (`update OFF`, `update of`) prints its usage and exits 2. It installed the latest release instead, so someone switching updates off got an upgrade and a daemon restart. ([21f0ebb](https://github.com/spacesheep-dev/sessionpipe/commit/21f0ebb))
- **patch** On Windows, `status`, `doctor`, `secrets` and `connect` say where a key lives (a file under your profile, not "0600": modes mean nothing there) and that updates run only while `sessionpipe control run` is running, since no service runs the daemon on Windows yet. ([5c0408c](https://github.com/spacesheep-dev/sessionpipe/commit/5c0408c))
- **patch** Devin: the transcript walk ends at a node it reaches a second time, so a session store whose parent chain loops back on itself no longer hangs the reader. ([4eb5a68](https://github.com/spacesheep-dev/sessionpipe/commit/4eb5a68))
- **patch** A session whose outbox file was pruned (untouched for `keep_days`, 30 by default) and then resumed under the same id is sent again. Before, pruning also deleted the session's seq counter but left each sink's cursor at the old file's end, so the resumed session restarted at seq 0, which a receiver's (harness, session, seq) deduplication drops as already seen, and the sinks read nothing from the new, shorter file until it grew past the old offset, then started mid-line. Now the counter stays and each sink's cursor into the pruned file is removed. ([7295fc9](https://github.com/spacesheep-dev/sessionpipe/commit/7295fc9))
- **patch** `sessionpipe-conformance` runs a single fixture file as well as a folder, takes `HTTPS://` in any case, and says in one line which path it could not read (the target, or an entry under it) or that a folder holds no `.json` fixtures (exit 2), instead of a stack trace. A file that is not an adapter fixture (a control vector, a delivery or redaction fixture), is not JSON, or cannot be read is a failure row naming why, instead of "no adapter undefined" or a throw that ends the run. ([898c7af](https://github.com/spacesheep-dev/sessionpipe/commit/898c7af))

## 0.13.0 (2026-11-03)

- **minor** `sessionpipe ask <agent|session> "<task>"`: a session hands a task to another agent on this machine and gets the answer on stdout (CONTROL.md §13). The request reaches the daemon on its local socket, never through a receiver. The daemon checks, in order and each with its code, that asking is on, that the agent is one it can run headlessly or a session it can message, the folder allowlist, the depth (A asks B, B may ask C, C may not), loops and the start caps, then runs the ask down the same start, prompt and headless paths a receiver's command takes, at `safe`, or `read` when asked (`--permission`), never wider than the machine's mode whatever it is set to (a session already live keeps its own). The daemon counts depth and loops for itself from the asks it has in hand, under the names it knows the sessions by, and takes three asks at a time per asker and per session asked; a `reply_to` it can't place when the command arrives makes the command no handoff at all (nothing relayed, no event). `ask on` from inside a session is refused unless `--yes`: allowing a folder and installing a service is the person's call. Words after `--` are the task, flags or not. The child's first message is the task and who asked; its environment carries `SESSIONPIPE_ASKED_BY` and `SESSIONPIPE_ASK_CHAIN` (the hook carries the first into the job, an Agent SDK session gets both, and a worker never leaks its own), so the child's events say `delegated_by` and the asker's say `delegation.started` and `delegation.ended`. At the terminal the id and progress go to stderr and the answer alone to stdout, exit 0; a refusal or a failure to stderr, exit 1; a usage error 2; `--json` prints `{ask_id, session, reply, ms}`. A `prompt` or a `start` with `reply_to` is a handoff: after its final `delivered` ack the daemon delivers the reply into that session as a message (§6 *Handoff*), and the events on `reply_to` say whether it was `delivered`, `unsupported` or `failed`. The pairing request and every hello say `ask` and `handoff: true`. `sessionpipe ask on|off|status`: `on` writes `ask: true` into control.json and runs the daemon as a service even with no receiver paired (`control run` serves the socket alone unless asking is off too; `control off` keeps the daemon when asking was turned on by hand); `off` sets it false and removes the service when no receiver remains; `status` prints the switch, the daemon, the agents it can ask and the folders. `sessionpipe ask setup [--remove]` writes a marked section into every Claude Code `CLAUDE.md` and `~/.codex/AGENTS.md` so a session knows the command exists and must never use it to send its own reply; hooks still inject nothing. ([aa44c74](https://github.com/spacesheep-dev/sessionpipe/commit/aa44c74))
- **minor** `sessionpipe channel`: a Claude Code channel, so a message reaches a live session in its own thread. Claude Code runs it as one session's MCP server (`claude mcp add --scope user sessionpipe -- sessionpipe channel`, then `claude --dangerously-load-development-channels server:sessionpipe` while channels are a research preview). It parks on the daemon's local socket as that session's waiter for as long as the session runs, and pushes each message in as a `notifications/claude/channel`: the session takes it in its own permission mode, as the person's own words, and shows it as a message from `sessionpipe` in the terminal and in the Claude app. The daemon routes to it as a waiter while the session is idle and reads the answer back from the transcript. The transcript reader now keeps a channel's message (`origin.kind: "channel"`, wrapped in `<channel source="…">`) as its turn's ask: a turn asked through a channel, Telegram's included, used to reach a receiver with nothing asked. ([aedb6e9](https://github.com/spacesheep-dev/sessionpipe/commit/aedb6e9))
- **patch** Control: a `claude --help` that couldn't be read is never taken for a Claude Code with no permission modes (#152). `detectCaps` marks a run that times out, or fails without listing `--permission-mode`, as `unread` (and takes a timeout, 30 s by default); `modeFlags` then refuses every level but `inherit`, saying why ("this machine couldn't read its Claude Code's options (`claude --help` timed out after 30 s); try again in a minute"), where it used to refuse `auto` as a mode that Claude Code lacks and to run `safe` with no permission flag at all. The daemon asks an unread `--help` again a minute later, and logs each one, instead of keeping it until a restart. A daemon that started 82 s after a Mac's reboot, beside 28 other Claude Code processes, kept such a read for 8 h 12 min, and refused the message sent in that time.
  
  While that `--help` is unread, the hello offers no Claude Code start at all (an offered `safe` was taken, and then run with no permission flag), and a start is refused before it is taken, never run with an empty argv. ([b4a0d39](https://github.com/spacesheep-dev/sessionpipe/commit/b4a0d39))

## 0.12.0 (2026-11-03)

- **minor** A session waiting on its own background work no longer reads idle on a receiver until the work ends (#153). `turn.ended.background` leaves out sessionpipe's own waiter (`sessionpipe wait`, which ends only when a message arrives, and so held every reachable session at "working"), `subagent.ended` carries `background`, the count once that subagent is out of it, and PROTOCOL.md §4 says a receiver SHOULD keep such a session as working until its next `turn.ended` or `session.ended`, not until the next event, which was always the workflow's own. Fixtures: `harness/claude-code/stop-waiter` (recorded on 2.1.312) and `subagent-stop`. ([6e4890b](https://github.com/spacesheep-dev/sessionpipe/commit/6e4890b))
- **patch** Control: a permission prompt in a turn the daemon itself runs (a session started or continued from a receiver, held through the Agent SDK or resumed headless) now waits for the owner's signed answer, as a terminal session's does. It used to skip that wait, so the receiver showed Allow and Deny, every Allow was refused `no_attention` ("that permission is no longer open"), and the turn sat on the prompt with the next message queued behind it. The Stop hook of such a turn still never asks: the daemon hands that session its next message itself. ([e8c0da2](https://github.com/spacesheep-dev/sessionpipe/commit/e8c0da2))
- **patch** Windows: a harness installed with `npm install -g` is a `.cmd` shim (`claude.cmd`, `codex.cmd`, `gemini.cmd`), which node refuses to spawn without a shell (EINVAL, since 20.12). `doctor` and `connect` died with `spawn EINVAL` on such a machine, `claude --help` silently read as a Claude Code with no capabilities, and every headless turn the daemon ran for Claude Code, Codex or an ACP agent failed the same way (#165). Now the shim's own script (the one npm's shim names) is run under the node sessionpipe runs on, with no shell, so a prompt reaches the harness byte for byte; a probe with fixed words (`--help`, `auth status`, `login status`, `--version`) whose launcher can't be read that way runs through a shell as one quoted command; and a headless turn whose launcher can't be read says so instead of spawning it. Elsewhere nothing changes. ([46f9ef1](https://github.com/spacesheep-dev/sessionpipe/commit/46f9ef1))

## 0.11.0 (2026-10-29)

- **minor** Session names from claude.ai, off until the person turns them on (`sessionpipe titles on`, config.json `remote_titles`). claude.ai names a session the Claude app starts seconds after its first message and never writes that name to the machine, so until now such a session went out under its first prompt cut to 50 characters while the Claude app showed another name. With it on, the worker asks claude.ai for the name of each Claude Code session with a claude.ai link (`GET https://api.anthropic.com/v1/code/sessions/<session_…>`, as Claude Code reads a session; the answer recorded in `conformance/remote/claude-code/`), a minute apart until it has one, then every half hour, within 3 s, and sends it as `session.title` with `title_source: "harness"`; a `custom` title still wins. `titles on` and `titles refresh [--days N]` also ask about the last 30 days' sessions and send each one already sent under another name again as `session.backfill`, so finished sessions are renamed too; a backfill takes these names as well. It bears the sign-in Claude Code keeps for that config dir (the macOS keychain item, else `.credentials.json`), sends it to `api.anthropic.com` alone, refuses redirects, never refreshes an expiring token and never writes it anywhere. Adapters gain an optional `remoteFacts` and `BackfillRow.transcript`; `Config` gains `remote_titles`. spec/ADAPTERS.md, spec/PRIVACY.md §3 and the `session.backfill` row of spec/PROTOCOL.md say so. ([91a1e57](https://github.com/spacesheep-dev/sessionpipe/commit/91a1e57))
- **minor** `limits.observed` reaches tier-1 sinks: after a Codex turn when its rollout holds new numbers, and from Claude Code's status line with `install --status-line` (or `connect --status-line`; `--no-status-line` takes it away; remembered in the config). A status line hides most of Claude Code's footer hints, so it is opt-in. The line shows the limits (`5h 23% · week 41%`) and hands the worker a job only when they changed, at most once a minute per config dir and never without a tier-1 sink. Before this a receiver could not tell which account still had room in its 5-hour or weekly window. ([3f074d8](https://github.com/spacesheep-dev/sessionpipe/commit/3f074d8))
- **minor** The pool: several Claude accounts behind one loopback listener the control daemon runs, so a Claude Code session keeps going when one account's limit is reached. `sessionpipe pool add --import` copies this machine's Claude Code logins into the pool (`--login` signs another account in: a URL, then the code pasted to `--code -`), `pool on` writes the `env` block into every Claude Code `settings.json` and installs the daemon's service if it is missing, `pool` lists the accounts in order with the room each has left, `order`, `enable`, `disable`, `remove` and `refresh` shape the roster, and `pool off` removes exactly what `on` wrote. A request goes to the first account in your order that has room; a session stays on its account while that account has room; the numbers an account reports on a session's request become a tier-1 `limits.observed` with `account_id` on that session, through the outbox like a hook's. Every hello carries the pool (`pool` object), a receiver's signed `pool` command drives it (`on`, `off`, `remove`, `order`, `disable` and `login.complete` need a fresh confirm, never a day grant alone: `confirm_required`), and while the daemon runs the CLI's commands go to it over the local socket, since it holds the listener, the pins and the credentials. The daemon now runs for the pool alone, with no receiver paired, and `control off` keeps its service while the pool is on; `sessionpipe uninstall` runs `pool off` first. Nothing of this happens until you run `pool add` or `pool on` yourself. ([227e247](https://github.com/spacesheep-dev/sessionpipe/commit/227e247))
- **minor** The account pool's contract (CONTROL.md §2 `pool`, §6 `pool`; PRIVACY.md §3): a hello MAY carry `pool` (`on`, `harness`, `accounts` in the machine's order with `status`, `current`, `until`, `limited_by` and the idle poll's `windows`; present in every hello of a daemon that knows the pool, `on: false` and no accounts when nothing is set up), and a new signed kind `pool` on the pseudo-session `sessionpipe:pool` (`pool_action`: on, off, enable, disable, remove, order, refresh, login.begin, login.complete; `pool_account`, `pool_order` of at most 20, `pool_code` of one line ≤ 512 characters), verified like `key.add`: each action takes exactly its own fields (`poolProblem`, `POOL_SESSION`, `POOL_ACTIONS`). `limits.observed` gains `account_id`, the account the numbers belong to when a pooled session was served by another than its own; tier 1 carries it and `pii@1` drops it as it drops `session.account_id`. Schemas: `control-command`, `control-hello`, `event-data`; control vectors 137–164. ([5bfc8cb](https://github.com/spacesheep-dev/sessionpipe/commit/5bfc8cb))
- **patch** Codex's config.toml keeps its CRLF line ends through `install` and `uninstall`: the notify line goes in with the file's own line end, and `uninstall` leaves the file byte-identical, as it already did for an LF file. ([83929e3](https://github.com/spacesheep-dev/sessionpipe/commit/83929e3))
- **patch** `install`, `status` and `doctor` read Gemini CLI's version on Windows, where the CLI is npm's `gemini.cmd` shim; before, every Windows install said "could not run `gemini --version`" and the 0.46 check never happened. ([b5285de](https://github.com/spacesheep-dev/sessionpipe/commit/b5285de))
- **patch** A hooks file that starts with a UTF-8 byte-order mark (PowerShell 5's UTF8 encoding, older Windows editors) is read like any other and written back with the mark, so `install` hooks it instead of reporting "not plain JSON; not touched", and `uninstall` leaves it byte-identical. The harnesses read such a file themselves. ([1891dab](https://github.com/spacesheep-dev/sessionpipe/commit/1891dab))
- **patch** `sink add` and `sink remove` rewrite the hooks when a sink crosses tier 0, as `install`'s own message says they will; before, the hooks stayed the lean set, `status` read `stale`, and `doctor` blamed an old node or script path. `doctor` now says which set the hooks are and why that no longer matches, when that is the cause. ([2153f5b](https://github.com/spacesheep-dev/sessionpipe/commit/2153f5b))
- **patch** `sessionpipe tail` names the outbox with the platform's own separator: `~\AppData\Local\sessionpipe\outbox` on Windows, where it read `…sessionpipe/outbox`. ([caa12b5](https://github.com/spacesheep-dev/sessionpipe/commit/caa12b5))

## 0.10.0 (2026-10-26)

- **minor** Control can start, prompt and cancel any agent that speaks the Agent Client Protocol (CONTROL.md §6, `api` mode; ADAPTERS.md "Agents driven through ACP"). The daemon runs the agent's own CLI as a child on the launch line the ACP registry publishes (Gemini CLI, OpenCode, goose, Copilot CLI, Cursor, Droid, Devin, Kilo, Qwen Code, Cline and twenty more, 2026-10-22), is its JSON-RPC client, keeps the process for the next message, cancels with `session/cancel`, reopens an idle session with `session/load` where the agent can, and answers its permission requests from the machine's mode (safe refuses and the reply says what; auto allows). Only an agent already installed is ever run (on PATH, or where `SESSIONPIPE_ACP_<HARNESS>` points); the hello advertises the ones found. The pairing request and every hello list at most 20 harnesses, the schema's limit (Claude Code, Codex and Antigravity first, then the agents in the registry's order), and their `modes` include `api` whenever an ACP agent is listed. ([5ea2f11](https://github.com/spacesheep-dev/sessionpipe/commit/5ea2f11))
- **minor** A Claude Code home that runs on another API now has an account. Such a home has no claude.ai login, and its `settings.json` sets `env.ANTHROPIC_BASE_URL` (Kimi K3 on a Modal endpoint, GLM on Z.ai). Its events carry `account_id: api-<16 hex>`, made from the endpoint's origin and the same on every machine that uses it. Control's pair and hello name it by the endpoint's host, in `accounts[].name`, a new optional field. Before this, its sessions carried no account, so a board filed them with the harnesses that report none (a person's Kimi sessions sat in "No account" beside 1,331 Codex ones). Only that one settings key is read, never the token or the headers beside it. ([7b65074](https://github.com/spacesheep-dev/sessionpipe/commit/7b65074))
- **minor** Control: device keys (CONTROL.md §2, §3.2). `sessionpipe-core/control` verifies a device key's signature (`{cred, sig}`, raw r‖s over `sessionpipe.device.v1⏎<purpose>⏎<rp id>⏎<challenge>`) for a grant, a confirm or a pairing proof, chosen by the machine's own record of the key; `keyUsable` closes key fields and checks a device key's rules (`type: "device"`, `alg -7`, `store: "hardware"`, `uv: "each-use"`, id = SHA-256 of the SPKI); `verifyCommand` refuses a `key.add` whose key could never be used (`bad_fields`). `verifyAssertion` takes the purpose. New exports `DEVICE_CONTEXT`, `deviceMessage`, `deviceKeyId`, `KEY_TYPES`, `DEVICE_SIG_FIELDS`, `DeviceSignature`, `DeviceGrant`. The daemon advertises `key_types: ["passkey","device"]` when pairing and in every hello, stores a device key whole (from a pairing or a `key.add`), and `control keys` shows each key's kind. 26 new shared vectors (`111`–`136`). ([27c4194](https://github.com/spacesheep-dev/sessionpipe/commit/27c4194))
- **minor** Control: a third machine mode, `inherit`, so sessions you message run the way your own sessions on that machine run. `sessionpipe control mode inherit` (or `--mode inherit` on `control pair` and `connect`, or the whole word at pairing's question) passes no permission flag, so each harness starts every turn in its own default there: Claude Code in its settings' `permissions.defaultMode` (a resume too, and through the Agent SDK too), Codex as its `config.toml`'s `approval_policy` and `sandbox_mode` say, Antigravity with no flag, as in safe. No mode passes a bypass flag. Before this, a machine whose Claude Code settings turn auto mode off (`permissions.disableAutoMode`) had no mode that fit: `auto` ran each messaged session in Claude Code's default mode, which refused every command that needed approval, and `safe` refuses them by design. Schemas: `control-pair` and `control-hello` `mode` gains `inherit`. ([abcba7f](https://github.com/spacesheep-dev/sessionpipe/commit/abcba7f))
- **minor** Control: a machine runs in `inherit` mode only with receivers that know it. A receiver that predates `inherit` shows such a machine as safe, while its sessions run with no permission flag. A receiver now says it shows the mode as what it is with `control.inherit: true` in its well-known file (HTTP.md §1, CONTROL.md §2), and `sessionpipe control pair --mode inherit` (and `connect --mode inherit`) refuses, before anything is sent, a receiver that doesn't: "… doesn't know inherit mode yet; it would show this machine as safe while the sessions you message run with no permission flag". `sessionpipe control mode inherit` checks every paired receiver the same way and keeps the mode it had when one doesn't know it, or can't be read. Schema: `well-known` `control.inherit` (optional boolean). Conformance: `delivery/23-control-inherit`. ([531b391](https://github.com/spacesheep-dev/sessionpipe/commit/531b391))
- **minor** **For receivers.** What a 0.10.0 machine sends and accepts, and how to adopt it while some machines still run 0.9.0 (CHANGELOG.md, "For receivers (0.10.0)", has the detail).
  
  - **Closed command field set.** Each command kind carries only its own fields; any other field inside the signed `cmd` is refused `bad_fields`, by the verifier and on the machine (0.9.0 ignored it). Keep your own data beside `cmd`, never in it.
  - **`mode: "inherit"`** in pairing requests and hellos: no permission flag, so a start that names no level runs as the person's own sessions there do, permission checks off included where their settings say so. Show it as `inherit`, never as safe or auto. A 0.10.0 machine pairs in inherit, or switches to it, only with a receiver whose well-known file says `control.inherit: true`; it tells everyone else's person "… doesn't know inherit mode yet; it would show this machine as safe". Set the flag once you show the mode truthfully and accept it in `/pair` and `/hello` (conformance `delivery/23`).
  - **Refusal code `mode` is now `permission`**, plus `refused` `model` and `failed` `model`. Map both codes; a 0.9.0 machine still says `mode`.
  - **Hello:** `start` (per harness: `permissions` among `read`, `safe`, `edits`, `auto`, capped by the mode; the default `permission`, which may be `inherit`; `models`, `any_model`), `prompt_files: true`, and no `home`: a start's `cwd` may be `~` or `~/…` instead. `harnesses` lists at most 20, and `modes` include `api` when an ACP agent is listed.
  - **Acks:** a start's `taken` and `delivered` carry `applied` (`permission` may be `inherit`). Show `applied`, never what was asked.
  - **Events:** `session.attended`, `turn.ended.background`, `turn.started.submitted_by` and `injected`, `session.launched_by`, and `account_id` `api-<16 hex>` for a Claude Code home on another API, named in `accounts[]` by `name` with no `email`. All optional, tier 0.
  - **ACP agents** (gemini-cli, opencode, goose, cursor, copilot-cli, droid and more) appear in `harnesses` and in ack `session` refs, acked in mode `api`; they take no start options.
  - **Device keys:** an app that can't run WebAuthn signs with a hardware-held P-256 key, `{id, alg: -7, spki, type: "device", store: "hardware", uv: "each-use"}`, its signature `{cred, sig}` (plus `str` in a grant) where an assertion would stand; a 0.10.0 machine says `key_types: ["passkey","device"]` when pairing and in every hello. To accept them, store and carry the key whole (all six fields) in a pairing's and a `key.add`'s `key`, pass a `{cred, sig}` grant, confirm or pairing proof through untouched, and say `control.signing.key_types: ["passkey","device"]` in your well-known file. They are added only as passkeys are: at the terminal's pairing code, or by a `key.add` a trusted key signed. A receiver that doesn't know them keeps working, passkeys only.
  - **While machines run 0.9.0:** send `permission` or `model` only where the last hello's `start` advertises them (0.9.0 ignores them silently); `files` on a `prompt` only with `prompt_files: true` (0.9.0 refuses them); a `~` folder only to a machine whose hello has `start`; never `inherit` as a command's level; a device key only for a machine whose last hello lists `device` in `key_types` (0.9.0 would store it as a passkey and refuse its signatures); read an absent `attended`, `background`, `submitted_by`, `injected` or `launched_by` as unknown, never as false, 0 or a person. ([2865e0d](https://github.com/spacesheep-dev/sessionpipe/commit/2865e0d))
- **minor** A control `prompt` can carry `files`, as a `start` can (CONTROL.md §3.1, §6 `prompt`), to a machine whose hello says `prompt_files: true`. The daemon fetches and checks them as for a start, writes them to `<cwd>/.sessionpipe/files/<session>/<nonce>/` (the command's signed nonce, never the receiver's unsigned message id), and appends the same "Attached files" block before the message is handed over in any mode: a resume, a fork, a held SDK session, an ACP agent (`api`), a waiter or a Stop. A file that fails is acked `failed` with code `file` and the message is not delivered. A receiver no longer needs to put a link to an image into the message's text. ([a6f8bb7](https://github.com/spacesheep-dev/sessionpipe/commit/a6f8bb7))
- **minor** `session.attended` says whether a person attends the session: an optional tier-0 boolean in the harness's own word, absent when the harness does not say (unknown, never `false`). Claude Code fills it from `CLAUDE_CODE_SESSION_ATTENDED`, counting a control turn and a session with a link (its Remote Control bridge, the desktop app) as attended: every headless run says `0`, a Remote Control session included, and a `claude -p` started inside one inherits `CLAUDE_CODE_ENVIRONMENT_KIND=bridge`, so the link is what tells them apart. The hook carries `CLAUDE_CODE_SESSION_ATTENDED` and `SESSIONPIPE_CONTROL_JOB` into the job, and the worker takes those and `CLAUDE_CODE_HOST_SESSION_ID` from the job alone, so a worker another session's hook spawned no longer lends a swept job its own. Codex, Gemini CLI, Antigravity and Devin leave it absent. ([d51fd31](https://github.com/spacesheep-dev/sessionpipe/commit/d51fd31))
- **minor** `session.launched_by`: the session that started this one, as `<harness>:<id>`, read from `SESSIONPIPE_LAUNCHED_BY` in the hook's environment (PROTOCOL.md §3). A script that runs `claude -p` for a session sets it, and a receiver can group the run under its launcher without reading it as a subagent. A subagent keeps its `parent_id` and gets no `launched_by`. ([fae77a5](https://github.com/spacesheep-dev/sessionpipe/commit/fae77a5))
- **minor** A control `start` can say how much the new session may do and which model it runs (CONTROL.md §2, §3.1, §5 step 8, §6 Options; ADAPTERS.md "Start options"). `permission` is one of four levels, `read`, `safe`, `edits` and `auto`, with nothing above `auto`; `model` is the harness's own id, passed as one argument. The machine's `mode` is the ceiling, and a start above it, or at a level the harness has no flag for, is refused `permission` before anything is taken or run (the daemon's old `mode` code). On a machine in `inherit` mode a start that names no level runs with no permission flag, as the person's own sessions there do, and one that names a level may name only `read` or `safe`, since sessionpipe can't rank the person's own default; `inherit` is never a level a command names. The hello advertises what each harness takes here (`start`, capped by the mode: Claude Code's levels from its `--help`, Codex's models from its own cache; a harness's default may be `inherit`). A start's `cwd` may be `~` or `~/…`, read under the daemon user's home, so a receiver can start a session in a folder an event reported without the machine ever telling it its home. A start's level and model are recorded with the session, and every later resume or fork runs at the narrower of that and the machine's mode then (at most `safe` on an inherit machine for a session that named a level). The `taken` and `delivered` acks of a start carry `applied` (its `permission` may be `inherit`); a model the harness rejects ends `failed` with code `model`. An ACP agent takes neither option: a start that names one is refused, never run without it. Each command kind now has a closed set of fields, so a field a machine doesn't know is refused rather than ignored. `sessionpipe control status` shows the options per harness. A start whose text begins with a dash no longer reaches `claude -p` as a flag. ([bd49ef7](https://github.com/spacesheep-dev/sessionpipe/commit/bd49ef7))
- **minor** `turn.ended` carries `background`: how much background work the harness reports still in flight as the turn ends, each piece of which wakes the session when it finishes. Claude Code's Stop hook lists it as `background_tasks` (in every Stop recorded since 2.1.258): a background shell command, a monitor, a subagent, a workflow. A session that ended its turn waiting on a two-hour background command read as finished on its receiver, under "Last 24 hours", until the command woke it. Tier 0, a count only: never what that work runs. ([d7269b4](https://github.com/spacesheep-dev/sessionpipe/commit/d7269b4))
- **minor** `turn.started` can now say who started the turn: two optional tier-0 fields, `submitted_by` (the harness's own word for who submitted the prompt, forwarded verbatim and never mapped onto a vocabulary of the spec — a different vocabulary from `session.started.source`) and `injected` (`true` when the prompt text is a block the harness wrote rather than words a person typed). Claude Code fills `submitted_by` from `UserPromptSubmit.source` when a payload carries that key — its hook schema documents `user` · `sdk` · `system` · `loop_wakeup` · `schedule_wakeup` · `poll_event` and says a payload may omit it while the field rolls out, and no build through 2.1.304 sends it — and sets `injected` from a closed list of its own prompt wrappers (`<task-notification>`, `<cross-session-message …>`, `<system-reminder>`, `<local-command-stdout>`, `<command-…>`), so a task notification, a peer message or a slash-command expansion is distinguishable from a person typing. `injected: true` says only that the prompt is not the person's words, never that no person caused the turn: a Talk or `sessionpipe wait` delivery arrives inside a `<task-notification>`. Both fields are absent when the harness does not say, and an absent field is unknown, never "the person". `sessionpipe tail` prints both. Codex, Gemini CLI, Antigravity and Devin leave both absent. ([f8770b9](https://github.com/spacesheep-dev/sessionpipe/commit/f8770b9))
- **patch** Two Node-only subpaths in `sessionpipe-core`, so a receiver of the person's own (a board on their machine) runs as a service with the daemon's code rather than a copy of it. `sessionpipe-core/service`: the launchd agent and the systemd user unit for any process (`serviceFiles`, `installService` and `uninstallService`, each with a dry run that answers the files and commands and touches nothing, and `ensureLinger`). `sessionpipe-core/version`: `compareVersions`, `isNewer`, `parseVersion`, `UPDATE_EXIT` and `supervise`, the parent that restarts a process onto new code where no service manager will. The root export is unchanged and still runs in a Worker and a browser. The control daemon now uses both; its service is the same, except that the unit's `ExecStart` quotes every argument. ([fc0fedd](https://github.com/spacesheep-dev/sessionpipe/commit/fc0fedd))

## 0.9.0 (2026-10-23)

- **minor** Control daemon: a `start` may bring files, for every harness it can start (Claude Code, Codex, Antigravity). The daemon fetches each from `{control}/files/{sha256}` with the machine's token, stops reading past the signed size, checks length and hash, writes them under `<cwd>/.sessionpipe/files/<session>/` (self-ignoring `.gitignore`, no symlinks followed, exclusive create, mode 0600) and starts the session with the text plus the list of relative paths. Any file problem acks `failed` with code `file` and starts nothing. Pair and hello now say `files: true`. ([55320c7](https://github.com/spacesheep-dev/sessionpipe/commit/55320c7))
- **minor** `sessionpipe control folders [add|remove <dir>…]`: change the folders that sessions you message may run in, at the machine, without pairing again. Before this, the folders were set only by `control pair --folder`. On 2026-10-13 a Kimi session in `~/claude-kimi` on a machine paired with `~/claude` was refused ("that session's folder isn't one this machine allows"), and the only fix was a new pairing, with its passkey approval. A folder to add must exist and can't be the home folder or above it (pair's rule). The running daemon picks up the new list within seconds, and when the folders or the mode change it sends a hello at once, so the receiver's folder picker doesn't wait up to ten minutes for the next one. ([ac26495](https://github.com/spacesheep-dev/sessionpipe/commit/ac26495))
- **minor** A `devin` adapter: Devin CLI (Cognition) sessions are reported like any other harness. `sessionpipe install` (or `--devin`) writes the eight event names Devin accepts into the `hooks` key of its user config file; the payload maps to the protocol's events, the folder comes from `DEVIN_PROJECT_DIR` (nothing in the payload names it), and tier 2, the model, the title and the times are read read-only from Devin's own SQLite session store — through a new `readers/sqlite.ts` that works on Node 20 too (in-process `node:sqlite`, else a short child process, else the `sqlite3` CLI, else no turns). Eight fixtures recorded from real runs on 3000.11.3. Also: the `claude-code` adapter now drops a payload with no `transcript_path` and a non-UUID session id — that is another harness running our entry in `~/.claude/settings.json`, not Claude Code — and the worker logs a dropped event instead of discarding it in silence when no adapter owns its harness name. ([d525320](https://github.com/spacesheep-dev/sessionpipe/commit/d525320))
- **minor** Review fixes to the Devin adapter and two gaps it exposed.
  
  `devin`: a turn is now timed by the message's own `metadata.created_at`, not by `message_nodes.created_at` — that column is the time the row batch was last **saved**, one value for the whole chain, rewritten on every save and not monotonic along it, so every turn of a session was reported at the same wrong instant. The chain read is now bounded by the cursor and caps from the newest end: past 4000 nodes the old query kept the 4000 **oldest** and then delivered nothing ever again, and every Stop re-read the chain's root (a 17 kB system prompt and four more system blocks). `installed()` reports `inactive`, not `current`, when the config file holds any event name Devin does not know — Devin then loads no hooks from that file at all, ours included — and `install()` says so too. A `tool_response` that is not an object no longer loses its output. `facts()` takes the hook's environment (new optional argument on the `Adapter` interface), so a job swept by a worker another harness's hook spawned still reads the right config and session store.
  
  `sessionpipe uninstall` takes the same `--<harness>` selection `install` has, removes the shared hook launcher only on a full uninstall, and refuses an all-harnesses uninstall when `SESSIONPIPE_CONFIG`/`SESSIONPIPE_STATE` point away from this machine while other harnesses would still be read from `$HOME` (pass `--all` to mean it). The hook forwards `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `APPDATA` in the job file, beside the per-harness variables already there. The control wait — up to 125 s on a `PermissionRequest` — now applies the same "this is not a Claude Code payload" test the worker does, so our entry in `~/.claude/settings.json` run by another harness returns at once. A payload no adapter claims is logged rather than dropped in silence.
  
  Tests: the Devin session-store tests build their fixture through the `sqlite3` binary where `node:sqlite` is absent, so they run on the Node 20 floor — where the `cli` engine is the only way into a database, and where all seven used to skip. Each engine is asserted where it works and skipped only for itself; `inlineParams` and the adapter's own chain query are covered. ([3fd0041](https://github.com/spacesheep-dev/sessionpipe/commit/3fd0041))
- **minor** `turn.progress` (tier 2): a turn still running goes out as it runs, not only when it ends. The prompt is sent as the turn starts, then what the assistant has said so far, at most every 20 s on a tool call and only when it changed, under the number its `turn.transcript` will carry. Over 20 000 characters the reply keeps its end. A 21-minute turn used to show nothing on its receiver for 21 minutes. ([0940788](https://github.com/spacesheep-dev/sessionpipe/commit/0940788))
- **patch** A batch a receiver refuses with 413 is halved and sent again until it fits, and one event too large on its own is dropped and logged (HTTP.md §2). Before, the sink stopped for the run and the next worker sent the same batch again, so one oversized batch stopped a sink for good. ([6ee80e1](https://github.com/spacesheep-dev/sessionpipe/commit/6ee80e1))
- **patch** An Antigravity session in a git checkout now opens its own conversation from its link, not Antigravity's home. Antigravity keeps a git checkout's folder under `gitFolder` in `~/.gemini/config/projects/<id>.json` (`{ "gitFolder": { "folderUri": … } }`) and only a plain folder's at the top of the resource, so the session's project was never found for a repo: the link had no `?p=c/<conversation>?section=<project>`, or named an enclosing plain folder's project instead (6 of 8 projects on one machine, 2026-10-20). ([8184e2a](https://github.com/spacesheep-dev/sessionpipe/commit/8184e2a))
- **patch** A busy session's events no longer wait 6 s and go back to the queue behind its own lock. A lock whose worker is gone (its 15 s deadline ended it without a release) is taken at once instead of after 5 min; a job polls for the lock every 50 ms instead of doubling its wait, so siblings can't keep taking it in between; and the sinks drain at once, so the lock is held for the slowest round trip, not the sum of them (33 re-queues in one day's log on a machine with three sinks, 2026-10-13). ([91265cb](https://github.com/spacesheep-dev/sessionpipe/commit/91265cb))
- **patch** Claude Code: a message the person sends while a turn is running now reaches `turn.transcript` and `turn.progress`. Claude Code records it as a `queued_command` attachment rather than a `user` record, so the transcript reader dropped it from tier 2. ([366043f](https://github.com/spacesheep-dev/sessionpipe/commit/366043f))
- **patch** The sessionpipe command's entry runs only when invoked as the script (through its npm bin link too), so every command can be tested in process. Output and exit codes are unchanged. ([023993a](https://github.com/spacesheep-dev/sessionpipe/commit/023993a))
- **patch** The usage table behind `--help` is exported (`usageTable`), so sessionpipe.org's docs page prints the same usage lines the CLI does; `--help` output is unchanged. The README's closing links sit at its end again, after "Help and status JSON". ([8253085](https://github.com/spacesheep-dev/sessionpipe/commit/8253085))
- **patch** Skip the parent's replayed history in forked Codex rollouts when reading session
  facts and transcripts. Wait for an event naming the fork's own thread, preserve
  its original transcript timestamps and cursors, and leave its title unset until
  it contains a real user ask of its own. ([94c1b2f](https://github.com/spacesheep-dev/sessionpipe/commit/94c1b2f))
- **patch** Codex: only the person's own threads become sessions.
  
  - An ephemeral thread is no longer reported: its hooks carry `transcript_path: null` and its notify finds no rollout. Codex Desktop runs its own background work this way (ambient suggestions and their safety review, a new chat's title), and so does `codex exec --ephemeral`. Each used to arrive as an untitled session with no turns, often with `/` as its folder.
  - A subagent's thread is no longer a session of its own. Its parent already reports it (`SubagentStart` / `SubagentStop`, its tool calls), so its notify and its rollout (backfill) report nothing. A fork the person made is still a session.
  - Inside a subagent, Codex sends the parent's `session_id` with the subagent's own rollout. That file is no longer read as the parent's transcript, so the parent's running turn (`turn.progress`) no longer shows a subagent's words.
  - Conformance: a harness fixture with an empty `expect` is a payload the adapter must report nothing for. ([81764cc](https://github.com/spacesheep-dev/sessionpipe/commit/81764cc))
- **patch** The control daemon retries pending events for every non-paused sink once a minute, including sinks whose token is in config.json. A session whose final delivery was refused no longer needs another hook event to reach its sink after the retry wait. The existing per-sink wait and machine-wide delivery lock still apply. ([e2ba5c8](https://github.com/spacesheep-dev/sessionpipe/commit/e2ba5c8))
- **patch** `sessionpipe control pair` names a first pairing the way the hooks name the machine: `machine` from config.json, else the hostname without its `.local`, `.lan` or `.home` suffix. On macOS the daemon used to register as `<name>.local` while events said `<name>`, so a receiver showed the same machine twice and could not route approvals to it. `--name` and an existing pairing's name still win. ([e252398](https://github.com/spacesheep-dev/sessionpipe/commit/e252398))
- **patch** Retry control polls after a 401 with a capped backoff, resume immediately when a hello accepts the token, and honour Retry-After on 503 responses. Show polling state, time since the last successful poll, and the last control error in status and doctor. ([1b286e6](https://github.com/spacesheep-dev/sessionpipe/commit/1b286e6))
- **patch** The control daemon's socket is now mode 0600, as spec/CONTROL.md §8 says. It used to take the umask (0755 on a default macOS), so only its 0700 parent kept other users out. ([56cb3ef](https://github.com/spacesheep-dev/sessionpipe/commit/56cb3ef))
- **patch** The hook and worker entries run only when invoked as the script, so both can be tested in process. What they do, print and exit with is unchanged; the hook still imports node builtins only and its timing is the same. ([ab5c975](https://github.com/spacesheep-dev/sessionpipe/commit/ab5c975))
- **patch** Ignore Claude Code's lone SessionEnd hooks (for example, from `claude mcp list`) when there is no outbox, stored facts or existing transcript. These hooks no longer create an outbox or reach a sink or the local daemon. Real sessions still send their end, including when their transcript was deleted but an outbox or facts remain. ([2d8a8ae](https://github.com/spacesheep-dev/sessionpipe/commit/2d8a8ae))
- **patch** No hook event is lost to a worker's 15 s deadline, and none waits for an unrelated hook. A worker used to delete its job file before running it, so one ended mid-run (a sink that hung, a lock held out) took the event with it. A job is now claimed by renaming its file and the claim is released only once its events are in the outbox; a sweep gives a claim whose worker is gone back to the queue. A job the session's lock held out used to wait in the queue for the next hook's sweep, however long that took; a fresh worker now starts for it. ([1ca23ee](https://github.com/spacesheep-dev/sessionpipe/commit/1ca23ee))
- **patch** A machine under a flood of hook events no longer floods its sinks. One worker drains the sinks at a time; the others leave their events in the outbox for it and it sends them in full batches of 50, so four busy sessions cost a request per batch, not per event. A sink that answered 429 is not tried again by any worker before its `retry-after`, and one that could not be reached waits 2 s · 10 s · 60 s · 5 min · 30 min, then every 30 min, as HTTP.md §2 has said all along (a machine with four busy sessions was refused 828 times in two minutes and had 5 192 jobs queued, 2026-10-14). The session's lock now covers only the append, not the round trips. ([434d10c](https://github.com/spacesheep-dev/sessionpipe/commit/434d10c))
- **patch** `control pair`: a code nobody approved before it expired is renewed in place, with fresh digits, for up to an hour. A person approving three headless machines from a phone ran out of ten-minute codes (2026-10-08). The expired code is dead on the receiver, so nothing lives longer than it did; a keyed pairing keeps the machine row its first code made. The terminal now also says how long a code is good for. ([6ea4095](https://github.com/spacesheep-dev/sessionpipe/commit/6ea4095))
- **patch** `turn.progress`: a message absorbed by a running turn, and text the person never typed, no longer open a turn of their own. Claude Code reports a message sent mid-turn (the person's, another session's, a task notification) as a prompt carrying the running turn's id; its reading went out numbered as the next turn, which the transcript never delivers, so a receiver kept showing it, with an empty reply, instead of the running turn. Such a prompt now leaves the reading on the turn the transcript is in, and a task notification that starts a turn is not shown as its ask. `isInjected` also recognises a wrapper tag with attributes (`<cross-session-message from="…">…</cross-session-message>`). ([dc14f84](https://github.com/spacesheep-dev/sessionpipe/commit/dc14f84))
- **patch** `sessionpipe connect --machine NAME --rename-machine` (and `install`) now renames a paired control daemon too, so the hooks and the daemon stay one machine on a receiver. Before, setup refused to touch a paired daemon's name, and a machine whose pairing predates the fix in 0.9.0 that names pairings like the hooks had no way to merge its two cards. The daemon says a new name in a hello at once, as it does for folders and mode. ([1a77ec3](https://github.com/spacesheep-dev/sessionpipe/commit/1a77ec3))
- **patch** After a 429, the HTTP sink waits as `retry-after` says when the header is an HTTP date (RFC 9110), not only when it is a number of seconds. Before, a date fell back to the 2 s backoff ladder. ([9314032](https://github.com/spacesheep-dev/sessionpipe/commit/9314032))
- **patch** A repeat `install` or `connect` on a config without a machine name keeps the name the hooks have been using (the hostname without `.local`), and renames a control daemon paired under another name to it, instead of adopting the daemon's. Before, a Mac paired before 0.9.0 (daemon `<host>.local`, events `<host>`) had its events switch to `<host>.local` on the first update, so a receiver showed its new sessions under a second machine. Receivers key a pairing by id, so the daemon's rename costs nothing. ([fd9c880](https://github.com/spacesheep-dev/sessionpipe/commit/fd9c880))
- **patch** A session's short id (PROTOCOL.md §3): the first 8 hex digits of `sha256("sessionpipe.session:" + harness + ":" + id)`, what a person reads in a log line, a notification or a search box. Derived, never sent. Not a prefix of the id: Codex ids are time-ordered, and four of ninety on one machine shared their first eight characters. `shortId()` in core; the worker's log and the daemon's lines that name a session use it. ([40fb6e5](https://github.com/spacesheep-dev/sessionpipe/commit/40fb6e5))
- **patch** Refuse connect/install downgrades of newer global packages or persistent hook copies,
  including newer prereleases, unless --allow-downgrade is explicit. Preserve the
  configured machine name when --machine differs; require --rename-machine for an
  unpaired rename and refuse names that would split sessions from a configured daemon. ([92bba52](https://github.com/spacesheep-dev/sessionpipe/commit/92bba52))
- **patch** Treat a hook stdin that is JSON but not an object (`null`, a number, a string, an array) as no payload in every adapter. Such a stdin made `fromHook` throw on the first field it read, and the worker job for that hook died with it; it is now handled exactly like an empty stdin, so the hook reports nothing and the session carries on. ([d5c7511](https://github.com/spacesheep-dev/sessionpipe/commit/d5c7511))
- **patch** Internal cleanup: fallbacks no input could reach are gone from the adapters, the privacy filter, the secrets ruleset, the outbox reader and the ULID. No behaviour changes; the public API is unchanged. ([4213f4e](https://github.com/spacesheep-dev/sessionpipe/commit/4213f4e))
- **patch** Write an `ssh://git@host/owner/repo.git` origin as `host/owner/repo` in `session.repo`, the same as the `git@host:owner/repo.git` form of that remote. Before, the two spellings of one repository reached a receiver as two different repos. ([ace786d](https://github.com/spacesheep-dev/sessionpipe/commit/ace786d))
- **patch** Internal: the home directory is read when a path is asked for, not when the package loads. A process whose HOME changes after import (a test with a sandbox home) gets the new one; no user-facing change. ([bea1cae](https://github.com/spacesheep-dev/sessionpipe/commit/bea1cae))
- **patch** Draining a large session file no longer re-reads the rest of it for every batch: a read takes 1 MB (more only for one event longer than that), not the remaining file up to 8 MB, and counts its offset per line rather than from the start. A 10 MB session drained in batches of 50 decoded 55 MB. ([cdb1dbf](https://github.com/spacesheep-dev/sessionpipe/commit/cdb1dbf))
- **patch** Recover a session's seq counter when its file is empty or not a number (a crash between truncate and write leaves it empty). Before, the counter restarted at 0 and the receiver's deduplication silently dropped every later event of that session; now the next seq is read from the outbox file's last line, as it already was when the counter file was missing. ([67cc4fc](https://github.com/spacesheep-dev/sessionpipe/commit/67cc4fc))
- **patch** The stdout sink answers with a retry result when its writer fails (a closed pipe under `sessionpipe tail | head`), as the file and HTTP sinks do, instead of throwing out of `send()`. ([cb36e47](https://github.com/spacesheep-dev/sessionpipe/commit/cb36e47))
- **patch** Stop the transcript cursor before a last line the harness is still writing. Before, a prompt whose line had no newline yet when the worker read the file was counted as read and never delivered, so the reply that followed arrived as a turn with an empty prompt. A complete last line without a trailing newline is still read. ([d4b085c](https://github.com/spacesheep-dev/sessionpipe/commit/d4b085c))
- **patch** The CLI entry runs only when invoked as the script, so the package can be imported by tests. Output is unchanged. ([a68bf97](https://github.com/spacesheep-dev/sessionpipe/commit/a68bf97))

## 0.8.0 (2026-10-09)

- **minor** Control's pairing request and every daemon hello name the Claude Code accounts signed in on the machine (`accounts`: `{harness, id, email}` per config dir, read again for each hello), so a receiver the person paired with can label `session.account_id` with the account's email instead of showing an id. Events are unchanged: `account_id` stays an id and no event carries an email. `sessionpipe control pair` and `connect` say which accounts they named. ([3c5d8fb](https://github.com/spacesheep-dev/sessionpipe/commit/3c5d8fb))
- **patch** Handle `--help` and `-h` before dispatching every CLI command, including nested commands, so asking for usage cannot install hooks, queue work, or contact a receiver. Implement `status --json` with doctor's names for shared facts plus pending session counts and queued jobs, preserving the human status output. ([93a8420](https://github.com/spacesheep-dev/sessionpipe/commit/93a8420))
- **patch** Build on TypeScript 7 (`baseUrl` dropped from every tsconfig; `paths` already resolved relative to the file). Dev-only: published output is unchanged. ([ea1d43a](https://github.com/spacesheep-dev/sessionpipe/commit/ea1d43a))

## 0.7.0 (2026-10-04)

- **minor** Control: messages reach Codex and Antigravity sessions too. The daemon starts a new
  session with `codex exec --json` or `agy -p … --output-format stream-json`, and resumes
  one with `codex exec resume` or `agy --conversation`, the way it drives `claude -p`:
  acked taken, the answer streamed as progress, then delivered with the reply. Both
  harnesses pick their own session ids, so the daemon records the one a start got
  (`state/control/aliases.json`) and keeps the start's id everywhere it reports the
  session: acks, later messages, and the hook events. A message sent right behind its
  start, even in the same poll, waits for it and resumes what it named. Safe mode runs
  Codex in its `workspace-write` sandbox and agy with no permission flag; auto mode passes
  `--approve-for-me` / `--mode accept-edits`; the bypass flags are never used. Hello and
  pairing advertise the harnesses installed on the machine. Core gains the runners
  (`codexControl`, `antigravityControl`), a shared headless spawner (`runHeadless`) and
  `drivableHarnesses`; the verifier accepts a `start` for any harness. ([5431264](https://github.com/spacesheep-dev/sessionpipe/commit/5431264))
- **patch** Codex: a session's turns and its title skip the AGENTS.md instructions Codex writes into the session as a "user" message, so a turn shows what the person asked. ([653245d](https://github.com/spacesheep-dev/sessionpipe/commit/653245d))

## 0.6.1 (2026-10-04)

- **patch** Antigravity: stop hooking PreToolUse. Antigravity reads a PreToolUse answer without a
  `decision` as a deny, so the `{}` sessionpipe printed blocked every tool call on every
  machine it was installed on, with no reason shown. Tool activity now comes from
  PostToolUse (a "still working" beat, since Antigravity names no tool there).
  `sessionpipe install` / `update` (and the daemon's auto-update) rewrite the old entry; a
  conversation that started before the rewrite still has it loaded, and gets an `ask` with
  a reason instead of a silent deny until it is restarted. ([9035be1](https://github.com/spacesheep-dev/sessionpipe/commit/9035be1))

## 0.6.0 (2026-10-04)

- **minor** The control daemon updates itself. About a minute after it starts and then once a day (the last check is kept in `state/control/update.json`, so restarts and sleeping machines don't ask more often), it reads npm's `latest`; when that is newer it waits until nothing is in hand (no delivery, headless run, start, SDK turn, open permission prompt, session mid-turn or ack flush), pauses intake, installs with `npm install --global --ignore-scripts` into the prefix it runs from, re-arms the hooks of the harnesses already set up, and restarts onto the new code: exit 75 under launchd or systemd (every service file ever written restarts on a non-zero exit; new ones also carry `SESSIONPIPE_SERVICE` and `RestartForceExitStatus=75`), and otherwise the old process stays as the new daemon's parent. Off with `sessionpipe update off` (`update_check: false`, now actually read), `SESSIONPIPE_NO_UPDATE_CHECK`, or when the copy isn't a writable global npm install. `status` and `doctor` show whether it's on, the last check and the latest version.
  
  One daemon per machine: `control run` no longer takes the socket from a daemon that answers on it (it says so and exits 0), steps aside if another daemon replaces its socket file, and never deletes another daemon's socket. `sessionpipe update` installs through the same path (npm run by the daemon's own node, the copy on disk checked, only the configured harnesses re-armed) and asks a running daemon to restart onto it once idle. ([f62bad9](https://github.com/spacesheep-dev/sessionpipe/commit/f62bad9))
- **patch** A started session's first message puts the person's words first and the "sent via" line after, so Claude Code names the session from what they asked instead of "Message via spacesheep.dev (sessionpipe control, s…". ([e474c81](https://github.com/spacesheep-dev/sessionpipe/commit/e474c81))

## 0.5.0 (2026-10-02)

- **minor** `sessionpipe connect <receiver>`: one command and one approval set a machine up — hooks for every agent and every Claude Code account, a sink at tier 2 with the key the receiver hands over at pairing, signed messages, keys in the keychain, a daemon that outlives logout, 30 days backfilled. It asks nothing (safe mode unless `--mode auto`; folders from `--folder`, the folder it ran in, or where recent sessions ran — never the home folder) and a second run only fixes what's missing.
  
  - Hooks run a launcher, `~/.local/share/sessionpipe/sessionpipe-hook`, with its own copy of the hook: a Node upgrade, `nvm uninstall` or a moved npm prefix no longer silently stops reporting. Re-installs move old entries to it in place.
  - Keys (sink tokens and secrets, machine tokens) live in the macOS Keychain or a desktop session's Secret Service when one is writable, else in the 0600 files as before; `sessionpipe secrets [move keychain|secret-service|file]`. A token a locked keychain won't hand over leaves events waiting (never unsigned); the daemon drains them from your session.
  - The Linux daemon turns on systemd linger, so closing ssh no longer stops it; on a Mac with nobody at the screen it runs as a background launchd agent instead of failing.
  - Before a headless run the daemon asks Claude Code whether that account is signed in (`claude auth status`) and fails the message with what to do — on a Mac, that a keychain login can't be read with nobody logged in at the screen — instead of Claude Code's "please run /login". `connect` and `doctor` list each account's state.
  - `sink add` keeps a sink's existing key when only the tier changes (it used to drop it); pairing fills in a sink that has no key and takes `--tier`; `control pair` run from the home folder no longer allows all of it; `control.json` keeps the machine's own caps across a write. ([6a5cda8](https://github.com/spacesheep-dev/sessionpipe/commit/6a5cda8))
- **patch** Run control pairing from the global install when invoked through npx, and use a stable Homebrew Node path for the daemon service. Existing service files are repaired by pairing again with the fixed CLI; install and update alone do not rewrite them. ([10d19eb](https://github.com/spacesheep-dev/sessionpipe/commit/10d19eb))

## 0.4.2 (2026-10-01)

- **patch** A message for a session the daemon's own run is still working on (a start, a resume) waits for that run and resumes the session, instead of forking a copy that would work the same task in parallel. ([4b39a06](https://github.com/spacesheep-dev/sessionpipe/commit/4b39a06))

## 0.4.1 (2026-10-01)

- **patch** `control pair` says the approval happens on any device where you're signed in to the receiver, not necessarily the machine being paired (which may have no screen), and that several machines are paired one by one, each with its own digits. ([98374b0](https://github.com/spacesheep-dev/sessionpipe/commit/98374b0))

## 0.4.0 (2026-10-01)

- **minor** `key.add`: a device the machine already trusts vouches for a new one (a phone with a different keychain), signed and verified like any command; the daemon adds the key to its store. Five new conformance vectors. ([b5cf64b](https://github.com/spacesheep-dev/sessionpipe/commit/b5cf64b))
- **minor** `sessionpipe control pair` asks once, at the machine, whether sessions you message run in auto or safe mode (no more silent safe default), and `sessionpipe control mode auto|safe` changes it; the running daemon picks it up within seconds. Message text may be 20 000 characters (command 48 000): a 4 000 cap silently cut real task briefs. ([37546aa](https://github.com/spacesheep-dev/sessionpipe/commit/37546aa))
- **minor** A receiver's well-known `control.session_tools` names MCP servers its sessions may use without asking (spacesheep names its own), so a safe-mode run answering a message can publish the page it was asked for. MCP server names only; built-in tools stay behind the person's approval rules. ([765b95a](https://github.com/spacesheep-dev/sessionpipe/commit/765b95a))
- **minor** Answers stream: a non-final `progress` ack carries the answer so far (whole, with a rising `seq`). The daemon streams headless runs from Claude Code's `stream-json` output (token by token) and in-place deliveries from the session's transcript (message by message); the final ack still carries the whole answer. ([2cd4475](https://github.com/spacesheep-dev/sessionpipe/commit/2cd4475))

## 0.3.0 (2026-09-30)

- **minor** Open pairing: `sessionpipe control pair spacesheep.dev` needs no key where the receiver declares `control.open_pairing`. One link, one passkey approval; the daemon proves itself on the status poll with a `poll_key` only it holds, prints which account approved, and adds the sessions key the receiver hands over as its events sink (tier 0). A bare host means https, and a `--token` with a character no key has (a pasted `ss_…` placeholder) is refused in words. ([79fc0a4](https://github.com/spacesheep-dev/sessionpipe/commit/79fc0a4))

## 0.2.0 (2026-09-30)

- **minor** The control daemon (M6): `sessionpipe control pair | status | keys | off | run` and `sessionpipe wait`. One daemon per computer verifies every command against keys enrolled at its terminal and delivers it in place when it can (a parked waiter, the session's next Stop, an Agent SDK session it started), else as a headless resume or fork; Stop and PermissionRequest hooks answer only for verified messages, and only where control is paired. Claude Code's PermissionRequest hook timeout is now 130 s (re-run `sessionpipe install`). A machine caps new sessions itself (2 at once, 10 an hour, `limits` in control.json) and runs at most 3 headless Claude Code processes at once; messages are handled concurrently, one session's in order; after a receiver outage the daemon is polling again within seconds and logs `back after N s`. ([54820be](https://github.com/spacesheep-dev/sessionpipe/commit/54820be))
- **patch** Control (M6): `sessionpipe-core/control`, the one verifier for signed control commands (WebCrypto only, safe in Workers and browsers), with 60 shared conformance vectors; the control schemas rewritten for signed, per-machine delivery. The CLI ignores `sink add --control` (control is per machine) and a retried batch reuses its `webhook-id`. ([87d9a65](https://github.com/spacesheep-dev/sessionpipe/commit/87d9a65))

## 0.1.1 (2026-09-28)

- **patch** The sink drain packs events from many sessions into one batch of up to 50 (a first install's backfill is a handful of POSTs, not one per session); cursors move only after the batch was acknowledged. ([8ae0d49](https://github.com/spacesheep-dev/sessionpipe/commit/8ae0d49))
- **patch** `sink test` sends a heartbeat and then its `session.forgotten`, so a receiver keeps no phantom session from the test. ([be95792](https://github.com/spacesheep-dev/sessionpipe/commit/be95792))
- **patch** Fixes from the first outside install (Yaroslav Bulatov, issues #8–#14 and advisory GHSA-8f6f-c3c9-j5p6): a config file that is not plain JSON is never written back; installs keep a file's indent and final newline and uninstall deletes files sessionpipe created; jobs that lose the session-lock race are retried and swept, never lost; Claude Code `StopFailure` → `turn.ended` with `reason: error`; `status`/`doctor` say when Gemini CLI won't run hooks (`tools.enableHooks`) and word the Codex review honestly; `doctor` measures the wall clock a harness waits; with no sink above tier 0 only one tool event per harness is hooked; the npx bootstrap installs into `~/.local` when npm's prefix is Homebrew's versioned Cellar; a bare `pass:` is no longer a secret name; the outbox holds redacted strings at rest in 0600 files under 0700 directories. ([b8b8599](https://github.com/spacesheep-dev/sessionpipe/commit/b8b8599))
- **patch** READMEs carry the published (unscoped) package names. ([08b5d89](https://github.com/spacesheep-dev/sessionpipe/commit/08b5d89))
- **patch** Published under unscoped names (sessionpipe-core, sessionpipe-receiver, sessionpipe-conformance) until the @sessionpipe npm org exists. ([a360ee8](https://github.com/spacesheep-dev/sessionpipe/commit/a360ee8))

## 0.1.0 (2026-09-28)

- **minor** Core: adapters for Claude Code, Codex, Gemini CLI and Antigravity; the secrets@1 and pii@1 rulesets and the tier filter; outbox, cursors, locks; file, stdout and HTTP sinks. CLI: install, uninstall, sink, status, doctor, tail, backfill, forget, replay, update; the hook entry imports only node: builtins. Conformance: adapter fixture runner. ([c75aaa0](https://github.com/spacesheep-dev/sessionpipe/commit/c75aaa0))
- **minor** Protocol v1 draft: the Zod source of truth (`packages/core/schema/v1.ts`) and the generated `schemas/v1/*.json`; inferred runtime types. ([e918475](https://github.com/spacesheep-dev/sessionpipe/commit/e918475))

## Protocol


Per-package changelogs are written by Changesets into each `packages/*/CHANGELOG.md`
and released in lockstep. This file records protocol-level changes.

### Unreleased

### For receivers (0.13.3)

A hello's `accounts` entries MAY carry `signed_in` (CONTROL.md §2): whether the
harness's command line is signed in to that account as the daemon sees it. A receiver
that sees `false` SHOULD say "run `claude auth login` on <machine>" before a message is
sent, where it used to learn that from the first message's failed ack. Absent means
unknown, never signed out. Fixture: `delivery/24` carries it.

### For receivers (0.13.1)

A receiver can now ask a machine to thin the tool events it is sent (PROTOCOL.md §4,
*Thinned tool events*): `tools: "main"` in its well-known file (HTTP.md §1), read at
`sink add` and `connect` and again by a worker once a day. **A receiver that says nothing
is sent every tool event, as before** — nothing changes on a machine's upgrade unless the
receiver asked, or the machine's owner set the sink's `tools`. Additive on protocol 1, and
every rule a receiver may already rely on still holds; where it asked:

- A subagent's tool calls (`data.agent_id` set) arrive as at most one `tool.ended` per
  session per 60 s, and never as `tool.started`. The main thread's arrive as before.
  `beat` (opt-in) thins the main thread's the same way. Nothing but `tool.*` is thinned.
- A thread's first `tool.ended` after its `attention.needed` is always sent, so "the next
  event that proves the prompt was resolved" (`attention.cleared`) still arrives.
  `attention.needed` now carries `agent_id` when a subagent asked.
- `seq` has gaps where events were thinned; a gap is never a lost event
  (`conformance/delivery/29-seq-gap.json`). A receiver MUST NOT expect a `tool.ended` for
  every `tool.started` or the reverse, nor count the tool events it got as the session's
  tool calls.
- A machine starts at most one request a second (HTTP.md §6); a receiver that limits
  requests per machine SHOULD allow at least that.
- A receiver that asked for `main` and later needs every tool event says `all` (or drops
  the field); the machine reads the file again within a day. `replay --tools all` re-sends
  them once.
- Codex: a subagent's tool calls and permission prompts carry `agent_id` (ADAPTERS.md).

### For receivers (0.13.0)

What a 0.13.0 daemon or hook sends and accepts beyond 0.12.0. Everything is additive on
protocol 1, and a receiver sees each capability in the machine's hello before it may use
it.

- **Hello and pairing request say `ask` and `handoff`** (CONTROL.md §2). `ask` is whether
  sessions on that machine may ask another agent (`sessionpipe ask`, §13: on unless the
  person turned it off); `handoff: true` says a `start` or a `prompt` may carry
  `reply_to`. A machine that says neither is 0.12.0 or older: send it no `reply_to`.
- **`reply_to` on a `start` or a `prompt`** (CONTROL.md §3.1, §6 *Handoff*): `<harness>:<id>`
  of a session on the same machine, never the command's own `session`, inside the signed
  bytes. The command runs and is acked exactly as before, one final ack; after a
  `delivered` ack the machine delivers the reply into `reply_to` as a message framed
  `Answer from <harness>:<short id> (sessionpipe handoff, asked via <receiver host>):`,
  through its normal prompt routing, never wider than `reply_to`'s own mode. On any other
  kind, malformed, or naming the command's own session: `bad_fields` (vectors `165`–`169`).
  A `reply_to` the machine can't place when the command arrives (no such session there,
  or one outside its allowlist) makes the command no handoff: it runs and is acked as
  usual, nothing is relayed, and no event is emitted on that session. The relay is not
  a command: it is not acked and never relayed further.
- **Two event types on the asking session** (PROTOCOL.md §4, tier 0):
  `delegation.started` (`ask_id`, `via`: `ask` · `handoff`, `harness`, `to?`; tier 1 adds
  `text`, ≤ 200 chars) and `delegation.ended` (`ask_id`, `to?`, `ok`, `ms`, `error?`,
  `relayed?`: `delivered` · `unsupported` · `failed`; tier 2 adds `reply`, ≤ 20 000 chars).
  They travel through the session's own outbox with its own `seq`. For a handoff,
  `delegation.started` comes when the machine takes the command (`to` is the command's
  session) and `delegation.ended` after its final ack; a command whose final ack isn't
  `delivered` relays nothing, and its `delegation.ended` has `ok: false` and no `relayed`.
  For an ask that starts a new Codex or ACP session, `to` is the id the daemon minted, the
  name the child's own events go by.
- **`session.delegated_by`** (PROTOCOL.md §3, tier 0): `<harness>:<id>` of the session this
  one was started or resumed for, on every event of the child, from `SESSIONPIPE_ASKED_BY`
  the way `launched_by` comes from `SESSIONPIPE_LAUNCHED_BY`. Lineage across harnesses,
  never authority: join it with `delegation.*.to`, and never read it as a subagent.
- **Nothing a receiver sends can start an ask.** It arrives on the daemon's local socket
  only (CONTROL.md §13); a receiver learns of it through the events.
- **Delivery mode `inbox`** (CONTROL.md §6, §6.3), between `turn` and `api`. A message
  for a session a live Claude Code holds open is written into that session's own local
  inbox, so the turn runs in that process, in the mode the person left it in, in the
  window or thread they are already looking at — where today it is answered by a copy.
  A hello lists `inbox` in `modes` where the mode exists; an ack's `mode` is `inbox`
  where it was used, with the session's own reply. A receiver shows that answer as the
  session's own, never as a copy's; one that doesn't know the value sees an unknown
  `mode` on a final ack that carries the reply anyway.
- **`failed` with `code: "held"`** (CONTROL.md §6.3, §10). The message was written into
  the session's own inbox and that session did not take it: no turn of its own began, or
  the turn that began was asked for by something else. Nothing was duplicated and no
  other mode was tried. **A receiver MUST NOT redeliver it**: the mode stays shut for
  that session while the message it wrote can still be released, so the same words again
  would put two copies of them in one place and a released hold would deliver both.
  Conformance `delivery/25`.
- **`refused` with `code: "ambiguous_home"`** (CONTROL.md §5, §6.1). One session id is
  filed in more than one of the harness's accounts on that machine and none — or more
  than one — holds it open, so the machine cannot tell which account the message is for.
  `detail` names the accounts by their directory name. Nothing ran.
- **`refused` with `code: "copy_limit"`** (CONTROL.md §5, §6 `prompt`). The machine has
  made the copies it makes in a day. A copy replays a whole conversation, so machines cap
  them; `detail` says so in words, and at a cap of zero says the machine makes none.
- **Ack `copy_of`** (CONTROL.md §6 `prompt`, §10), a `SessionRef`: this answer came from
  a **copy** of the session the command named, not from it. A `fork`'s ack carries the
  copy in `session` and the original here, and its `detail` may name the mode the copy's
  own turn ran in. A receiver shows whose answer the person is reading. Conformance
  `delivery/27`.
- **Event field `injected`** (PROTOCOL.md; optional, tier 2) on `turn.transcript` and
  `turn.progress`: the turn's ask reached the session from somewhere other than its own
  keyboard — another session on that computer handed it over, which is how the `inbox`
  mode reaches a session. The words are the person's; the arrival was not. It says of a
  turn's ask what `turn.started.injected` says of a prompt, and like it is never a claim
  that no person is behind the ask. Absent means a turn asked for where the session runs.

- **A channel delivers in place too** (CONTROL.md §8). `sessionpipe channel` is a Claude
  Code channel (its `--channels` MCP mechanism, the way the Telegram plugin reaches a
  session) that parks as the session's waiter and pushes each message into the live
  session, in its own thread and permission mode, as the person's own words; the daemon
  reads the answer back from the transcript as for any waiter. A channel is used only
  while the session is idle, and before `inbox`; its delivery is proven by the turn it
  causes, and a channel message the session began no turn for is acked `failed` with
  `code: "held"` like an inbox write (conformance `delivery/28`). A receiver sees the
  reply as the session's own, acked `mode: "waiter"`; a channel's ask is `injected`.
- **An `inbox` answer is a teammate's, to the session.** Claude Code tells the model a
  message arriving through its inbox came from another Claude session, not its user, so
  the session won't change its permission settings, CLAUDE.md or configuration for one,
  or take one as the person's approval of a pending prompt (CONTROL.md §6.3). A receiver
  SHOULD say so beside an answer acked `mode: "inbox"`.

Adopting 0.13.0 while machines still run 0.12.0:

- Offer a handoff (`reply_to`) only for a machine whose latest hello says `handoff: true`;
  an older machine refuses the command `bad_fields`, since its field set is closed.
- Read an absent `ask` or `handoff` as "not on this machine", an absent `delegated_by` as
  unknown, and keep a `delegation.*` event you don't know as you keep any other type.
- A receiver that pre-checks commands with a copy of the verifier re-vendors 0.13.0's
  `sessionpipe-core/control` and its vectors (`165`–`169` are new).


### For receivers (0.12.0)

What a 0.12.0 daemon or hook sends beyond 0.11.0: one reading in PROTOCOL.md §4, and one
optional field. Nothing a receiver has to adopt; the rest of 0.12.0 (Windows `.cmd` shims,
a permission prompt in a turn the daemon runs, a socket test) changes nothing on the wire.

- **A session waiting on its own background work stays "working" until its next
  `turn.ended`** (PROTOCOL.md §4, the `turn.ended` and `subagent.ended` rows; issue #153).
  The `turn.ended` row said a receiver MAY show such a session as working "until its next
  event"; the next event was always the work's own (a `subagent.started` seconds after the
  Stop), so a session that had handed its turn to a background workflow read idle while the
  workflow ran. It now says SHOULD, until the session's next `turn.ended` or
  `session.ended`, and that `subagent.*` and a subagent's tool events are not the session
  waking. Two things from the sender help: `subagent.ended` MAY carry `background`, the
  count once that subagent is out of it (`schemas/v1/event-data.json`; fixture
  `harness/claude-code/subagent-stop`), so a receiver sees the count fall to 0 before the
  next turn; and `turn.ended.background` no longer counts sessionpipe's own waiter
  (`sessionpipe wait`, CONTROL.md §8; fixture `harness/claude-code/stop-waiter`), which
  ends only when a message arrives and so held every reachable session at "working".
  Additive: a receiver that ignores the new field, or still drops the reading at the next
  event, keeps working as before.

### For receivers (0.11.0)

What a 0.11.0 daemon or hook sends, what it accepts, and how a receiver adopts it while
some machines still run 0.10.0 (a hello says which). Everything is additive on protocol 1:
one new event type, one new hello object, one new command kind.

- **`limits.observed`** (PROTOCOL.md §4; PRIVACY.md §1; `schemas/v1/event-data.json`): a
  new tier-1 event, OPTIONAL for senders, carrying the account's usage limits as the
  harness last reported them. `windows[]` (1–8), each `id` the harness's own name for the
  window, passed through as it spells it (`five_hour`, `seven_day`, `primary` …),
  `used_percent` (0–100; above 100 once a spend limit is passed), `resets_at?` and
  `window_minutes?` (only where the harness or its documentation says the length); `plan?`;
  and `account_id?`, the account the numbers belong to, spelled as `session.account_id` is.
  Each event is the whole set: a window it leaves out is not reported (Claude Code drops
  one once it resets), and a window whose `resets_at` has passed has reset, so a receiver
  SHOULD NOT show its last `used_percent` as current. The numbers belong to the account,
  not the session: a receiver SHOULD keep the newest per (harness, `data.account_id`, else
  `session.account_id`), or per machine when there is neither. A sender MUST set
  `account_id` when the numbers belong to another account than the session's own (a
  pooled session served by a second account) and MAY set it otherwise; absent, they are
  the session's account's. Three sources (ADAPTERS.md, the *Limits* rows): Codex's rollout
  after a turn (`primary`, `secondary`; only the account's own `codex` bucket), Claude
  Code's status line where the person opted in with `install --status-line`
  (`five_hour`, `seven_day`, `spend_limit`; `window_minutes` 300 and 10 080 for the first
  two), and the machine's account pool reading the vendor's rate-limit headers for a
  request it served. `pii@1` drops `data.account_id` as it drops `session.account_id`
  (PRIVACY.md §2). There is no recorded fixture for it yet; the schema is the contract.
- **A hello MAY carry `pool`** (CONTROL.md §2; `schemas/v1/control-hello.json`;
  conformance `delivery/24`): the machine's account pool, several accounts of one
  harness behind the daemon so a session keeps going when one account's limit is
  reached. `on`, `harness`, and `accounts` (at most 20) in the machine's order, each with
  `id` (the same string `session.account_id` and `limits.observed.account_id` carry for
  it, so a receiver joins the three without a map), `email` or `label` where known (they
  travel only here), `enabled`, a `status` — `ready`, `limited` (with `until` where the
  machine knows when), `error` (sign in again), `disabled` — `current: true` on the one
  serving new sessions now, and optionally `windows`, the account's freshest numbers in
  the shape of `limits.observed.windows` (from the idle usage poll, since an idle
  account's numbers ride on no session), and `limited_by` when the status is `limited`
  or `error`: the window's id (`seven_day`), or `rejected` or `error` for the causes that
  are no window. A daemon that knows the pool sends `pool` in **every** hello, `on: false`
  and `accounts: []` when nothing is set up, so a receiver tells "no pool" from "an old
  daemon"; it sends a hello at once when the pool changes, as it does for its folders. A
  receiver reads `windows` as it reads `limits.observed` and MUST NOT send it on as an
  event; it is a hello's reading, for the receiver's own page. **A hello without `pool`
  is a daemon that predates the pool: a receiver MUST NOT offer pool controls for it.**
- **The `pool` command** (CONTROL.md §3.1, §5, §6 `pool`;
  `schemas/v1/control-command.json`; vectors `137`–`164`): a new signed kind on the
  pseudo-session `sessionpipe:pool`, the only kind besides `key.add` that names no coding
  session. A receiver can only **ask**;
  the machine decides, acts and acks, and no command changes which account serves a
  request (the first in the person's order that has room; a session stays on its account
  while that account has room). `pool_action` is required and closed — `on`, `off`,
  `enable`, `disable`, `remove`, `order`, `refresh`, `login.begin`, `login.complete`; any
  other value is `bad_fields` — and each action takes exactly its own fields:
  `pool_account` (an id as the hello lists it, or a pending sign-in's,
  `[A-Za-z0-9._:-]{1,128}`) is required by `enable`, `disable`, `remove` and
  `login.complete` and optional on `refresh`; `pool_order` (1–20 ids, no repeats, every
  account of the pool once) belongs to `order` alone; `pool_code` (one line, ≤ 512
  characters) to `login.complete` alone. A `pool_*` field on any other kind, or on a `pool`
  whose action doesn't take it, is `bad_fields`. **Fresh confirm:** `on`, `off`, `remove`,
  `order`, `disable` and `login.complete` are verified by a fresh confirm (§3.2) or not at
  all; one that arrives on a day grant alone is refused `confirm_required` by the machine
  (vector `164` is the confirmed form). `enable`, `refresh` and `login.begin` take a grant,
  as a prompt does. A receiver MUST ask for the confirm where the machine will want it,
  and MUST show which machine it is for. **Acks:** `delivered` with no `mode` and `detail`
  = the machine's one-line summary of the pool, or for `login.begin` the pending login id,
  one space, and the URL to open; the daemon also sends a hello at once, which is the
  state a receiver shows. `refused` `pool_account` (an account or pending login the
  machine does not hold), `pool_order` (an order that does not name every account exactly
  once), `pool_off` (`refresh`, `login.begin` or `login.complete` while the pool is off).
  `failed` with `code` `login` (the vendor would not take the code) or `pool` (the vendor
  call a `refresh` or `login.complete` needs did not go through); `detail` says what,
  never a token. `unsupported` when the daemon knows the kind but this machine cannot
  pool. A daemon that **predates** the kind refuses it `unknown_kind` at the verifier.
  No command adds a credential from the receiver's side: `login.complete` carries a
  one-time code the vendor issued to the person, and the exchange happens on the machine.
- **The privacy contract has two opt-in exceptions now** (PRIVACY.md §3; ADAPTERS.md),
  both off until the person turns them on at the machine, and neither puts a token in an
  event, an ack, a log or a receiver. With `sessionpipe titles on` (`remote_titles`), the
  Claude Code adapter asks claude.ai for a session's name, the one fact claude.ai never
  writes to the machine; with `pool on` or `pool add`, the daemon holds the credentials of
  the accounts the person added and sends each one only to that vendor's own endpoints,
  for the person's own requests. A receiver learns the pool's accounts (ids, labels,
  emails) only in the hello, and their usage only as `limits.observed`.
- **Session names from claude.ai** (PROTOCOL.md, the `session.backfill` row; ADAPTERS.md
  Claude Code *Facts*; PRIVACY.md §3): with `remote_titles` on, a Claude Code session
  with a claude.ai link arrives as `session.title` with `title_source: "harness"`, a
  `custom` title still winning, and a session already sent under another name is sent
  **again as `session.backfill`** with the session's next `seq`; a receiver applies its
  session block as it does any event's. No new field; the recorded answers are in
  `conformance/remote/claude-code/` (`session-named.json`, `invalid-id.json`), fixtures
  for the adapter, not the receiver.
- The rest of 0.11.0 (sink tier rehooks, `tail`'s Windows path, Codex `config.toml` line
  ends, Gemini CLI's version on Windows, hook files with a byte-order mark) changes
  nothing a receiver reads, except that a BOM-marked `settings.json` on a home on another
  API now yields its `api-<16 hex>` `session.account_id` where it reported none.

Adopting 0.11.0 while machines still run 0.10.0:

- Keep `limits.observed` as the account's, not the session's: the newest per (harness,
  `data.account_id`, else `session.account_id`), per machine when there is neither, and
  read a window whose `resets_at` has passed as reset. A 0.10.0 machine never sends it.
- Show a pool only from the latest hello that carries `pool`, never from a guess, and
  show nothing for a hello without it. Keep `email` and `label` from the hello; no event
  carries them.
- Never send `pool` to a machine whose last hello had no `pool`: an older daemon refuses
  it `unknown_kind`, and nothing happens. Gate per machine on its latest hello.
- Ask for a fresh confirm before `on`, `off`, `remove`, `order`, `disable` and
  `login.complete`, and name the machine it is for; a day grant alone is refused
  `confirm_required`.
- Take `confirm_required`, `pool_account`, `pool_order`, `pool_off`, and `failed` `login`
  and `pool`, as codes you may not have seen, and show `detail`; an unknown code is still
  a refusal or a failure, never success.
- A receiver that pre-checks commands with a copy of the verifier re-vendors 0.11.0's
  `sessionpipe-core/control` and its vectors (`137`–`164` are new).

### 0.10.0 — 2026-10-26

What a 0.10.0 daemon or hook sends, what it accepts, and how a receiver adopts it while
some machines still run 0.9.0 (a hello says which). Everything is additive on protocol 1
except the first item, which tightens what a machine accepts, on purpose.

- **The command field set is closed** (CONTROL.md §3.1). Each kind carries the fields its
  table row names, plus `v`, `t`, `machine`, `kind`, `session`, `nonce`, `iat` and
  `note`. Any other field inside the signed `cmd` is refused `bad_fields`, by the
  verifier and on the machine; 0.9.0 ignored it. A receiver keeps its own data (a
  `match`, an `origin`) beside `cmd`, never inside it, so a field a machine doesn't know
  fails closed.
- **`mode: "inherit"`** (CONTROL.md §2) in a pairing request and a hello. The machine
  passes no permission flag, so each harness runs as the person's own sessions there do:
  Claude Code in its settings' `permissions.defaultMode`, `bypassPermissions` included,
  and Codex as its `config.toml` says. A start that names no level runs that way. A
  receiver shows the machine as `inherit`, on its pairing page and wherever it shows a
  mode, and never as `safe` or `auto`; an unknown mode is unknown, not safe.
  **The gate:** a 0.10.0 machine pairs in `inherit`, or switches to it, only with
  receivers whose well-known file says `control.inherit: true` (HTTP.md §1). Elsewhere
  the person is told "`<receiver>` doesn't know inherit mode yet; it would show this
  machine as safe while the sessions you message run with no permission flag", and the
  machine keeps its mode. A receiver sets the flag once it shows the mode truthfully and
  accepts `mode: "inherit"` in `/pair` and `/hello` (conformance `delivery/23`).
- **The refusal code `mode` is now `permission`** (CONTROL.md §5), beside a new
  `refused` `model`, and `failed` `model` when the harness rejects a start's model. A
  0.9.0 machine still says `mode`, so a receiver maps both; `detail` always has the
  words.
- **New hello fields** (CONTROL.md §2). `start`: per harness, the `permissions` a start
  may name there (among `read`, `safe`, `edits`, `auto`, already capped by the mode; on
  an `inherit` machine at most `safe`), the default `permission` (which may be
  `inherit`), and `models` / `any_model`. `prompt_files: true`: the machine takes `files`
  on a `prompt`. `files: true` is as in 0.9.0. There is **no `home`**: no hello names the
  machine's home folder, which would tell every receiver the OS user name. Instead a
  start's `cwd` may be `~` or `~/…`, the way events write a session's folder, and the
  machine reads it under its own home. `harnesses` lists at most 20 (Claude Code, Codex
  and Antigravity first), and `modes` include `api` whenever an ACP agent is listed.
- **Acks carry `applied`** (CONTROL.md §6, §10): a start's `taken` and `delivered` say
  what it runs with, `{"permission": …, "model"?: …}`, where `permission` may be
  `inherit`. A receiver shows `applied`, never what was asked; a delivered start with no
  `applied` reached a machine that ignored the options.
- **New event fields** (PROTOCOL.md; all optional, tier 0): `session.attended`,
  `turn.ended.background`, `turn.started.submitted_by` and `injected`,
  `session.launched_by`, and `session.account_id` `api-<16 hex>` for a Claude Code home
  on another API, named in the pair and hello `accounts[]` by `name` (the host) with no
  `email`. A receiver that doesn't know them keeps them as extra keys.
- **ACP harnesses** (CONTROL.md §6, ADAPTERS.md "Agents driven through ACP"): a hello's
  `harnesses` may name any agent from the ACP registry that is installed (`gemini-cli`,
  `opencode`, `goose`, `cursor`, `copilot-cli`, `droid`, `qwen-code` and more); their
  turns are acked in delivery mode `api`, and an ack's `session` may be
  `<acp-harness>:<id>`. They take no start options. A receiver shows a harness it has no
  label for by its raw name.
- **Device keys** (CONTROL.md §2, §3.2; HTTP.md §1). A desktop or phone app whose web
  view can't run WebAuthn signs with a P-256 key held in hardware and verified each use,
  `{id, alg: -7, spki, type: "device", store: "hardware", uv: "each-use"}`, and its
  signature is `{cred, sig}` (plus `str` in a grant) where a passkey's `{cred, ad, cd,
  sig}` would stand. A 0.10.0 machine says `key_types: ["passkey","device"]` in its
  pairing request and every hello. **To accept device keys**, a receiver stores and
  carries such a key whole, all six fields, in a pairing's `key` and in a `key.add`'s
  `key` (never trimmed to `id`, `alg`, `spki`); passes a `{cred, sig}` grant, confirm or
  pairing proof through as it came; and says `control.signing.key_types:
  ["passkey","device"]` in its well-known file. A device key reaches a machine only as a
  passkey does: approved at the terminal's pairing code, or by a `key.add` that a key the
  machine already trusts signed. **A receiver that doesn't know device keys keeps
  working:** with no `key_types` it is passkeys only, an app offers it none, and nothing
  about passkeys changed.

Adopting 0.10.0 while machines still run 0.9.0:

- Send `permission` or `model` on a start only when the last hello's `start[harness]`
  lists that level in `permissions`, or has `any_model` or `models`. A 0.9.0 machine
  ignores both in silence and runs at its own mode.
- Send `files` on a `prompt` only to a machine whose hello says `prompt_files: true`; a
  0.9.0 machine refuses the prompt `bad_fields`.
- Send a `~` folder as a start's `cwd` only to a machine whose hello carries `start`;
  give any other an absolute one (from `folders`).
- Never sign `inherit` as a command's `permission`: it only ever comes from the machine.
- Read an absent `attended`, `background`, `submitted_by`, `injected` or `launched_by`
  as unknown: never `false`, `0` or "a person". Keep a `submitted_by` you don't know.
- Offer a device key for a machine only when its latest hello lists `device` in
  `key_types`. A 0.9.0 machine would store it as a passkey and refuse every signature it
  makes (vector `122`).
- Gate per machine on its latest hello, cloud boxes included: one asleep on an old
  snapshot may still poll on 0.9.0.
- A receiver that pre-checks commands with a copy of the verifier re-vendors 0.10.0's
  `sessionpipe-core/control` and its vectors (`69` is now `files-on-permission-answer`;
  `90`–`136` are new; `verifyAssertion` now takes the signature's purpose).

### Protocol changes

- Control: **a session asks another agent, and a handoff brings the answer back**
  (CONTROL.md §2, §3.1, §6 *Handoff*, new §13; PROTOCOL.md §3, §4, §5; PRIVACY.md §1;
  still Draft). `sessionpipe ask <harness|session> "<text>"` reaches the machine's daemon
  on its local socket, never through a receiver; the daemon checks, in order, that asking
  is on, that the agent is one it can run headlessly or a session it can reach, the folder
  allowlist, the depth (two deep at most), loops and the start caps, each with its code
  (`ask_off` · `harness` · `folder` · `depth` · `loop` · `start_limit` · `bad_fields`), runs
  the child at `safe` (or `read`), never wider than the machine's mode, frames the task so the model answers in
  place, and sets `SESSIONPIPE_ASKED_BY` and `SESSIONPIPE_ASK_CHAIN` in the child's
  environment. A `prompt` or a `start` MAY carry `reply_to`, a session on the same
  machine that gets the answer as a message once the command's turn ends, through the
  normal routing and never wider; the verifier takes it on those two kinds only, never
  the command's own session. Two event types on the asking session, `delegation.started`
  and `delegation.ended` (tier 0; `text` at 1, `reply` at 2), and one session field,
  `delegated_by`, read by the hook like `launched_by`. Hello and pairing request gain
  `ask` and `handoff`. Schemas: `control-command` gains `reply_to`, `control-hello` and
  `control-pair` gain `ask` and `handoff`, `event` gains `session.delegated_by`,
  `event-data` the two types. Five new control vectors (`165`–`169`).
- Control: **device keys** (CONTROL.md §2, §3.2, §5, §11; HTTP.md §1; still Draft). An
  app whose web view can't run WebAuthn (a desktop or phone app) signs with a P-256
  key held in hardware and verified each use: `{id, alg: -7, spki, type: "device",
  store: "hardware", uv: "each-use"}`, its id the SHA-256 of its SPKI. Its signature
  is `{cred, sig}` (plus `str` in a grant), raw r‖s over
  `sessionpipe.device.v1⏎<purpose>⏎<rp id>⏎<challenge>`, purposes `grant`, `confirm`
  and `pair`; the challenge is the one a passkey would sign, so the day grant, confirms,
  pairing and `key.add` work unchanged. A machine verifies by its own record of the key,
  never the message. No new refusal codes. Machines advertise `key_types` in the pairing
  request and every hello, receivers in `control.signing.key_types`; a client offers a
  device key only where both list `device`. A `key.add` key is now checked like an
  enrollment (an SPKI the machine can import, and every device rule), `bad_fields`
  otherwise, so an unimportable key is never stored. Key fields are closed: a passkey
  carries `id`, `alg`, `spki` only. Schemas: `control-key` is a closed `oneOf`
  (passkey | device), `control-command` and `control-paired` use it, grant, confirm and
  pairing proof take either signature shape, `control-pair`, `control-hello` and the
  well-known `control.signing` gain `key_types`. 26 new control vectors (`111`–`136`).
- `turn.started` can say who started the turn (PROTOCOL.md §4; PRIVACY.md §1;
  ADAPTERS.md). Two new OPTIONAL tier-0 fields: `submitted_by`, the harness's **own** word
  for who submitted the prompt, forwarded verbatim and never mapped onto a vocabulary of
  this spec (a bare word, `[A-Za-z0-9][A-Za-z0-9_.-]{0,39}`, because tier 0 is not
  scrubbed; a different vocabulary from `session.started.source`, which a receiver MUST NOT
  merge with it); and `injected`, `true` when the prompt **text** is a block the harness
  wrote rather than words a person typed. `injected: true` says only that the prompt is not
  the person's words — never that no person caused the turn, since a harness may deliver a
  person's message inside such a block. A receiver MUST read an absent field as unknown,
  MUST keep a `submitted_by` it does not recognise, and MUST NOT let a second `turn.started`
  for a `turn_id` it already accepted reclassify that turn. Claude Code fills `injected`
  from a closed list of its own wrappers and would fill `submitted_by` from
  `UserPromptSubmit.source`, which no build in the field sends yet; fixture
  `conformance/harness/claude-code/user-prompt-submit-injected.json`. Additive: a receiver
  that does not know the fields keeps them as extra keys on `data`.
- `session.attended`: whether a person attends the session (PROTOCOL.md §3, §5; PRIVACY.md
  §1; ADAPTERS.md). A new OPTIONAL tier-0 boolean in the session block, in the harness's own
  word: `true` when a person is at the session or drives it from one of the harness's own
  clients, `false` for a run nobody watches. A receiver MUST read an absent field as unknown,
  never as `false`. Claude Code fills it from `CLAUDE_CODE_SESSION_ATTENDED`, counting a
  control turn and a session with a link (its Remote Control bridge or the desktop app) as
  attended, because every headless run says `0`, a Remote Control session included. The hook
  carries `CLAUDE_CODE_SESSION_ATTENDED` and `SESSIONPIPE_CONTROL_JOB` into the job, and the
  worker reads those and `CLAUDE_CODE_HOST_SESSION_ID` from the job alone, never from its own
  environment. Additive: a receiver that does not know the field ignores it.
- Control: a `start` may carry `files` (CONTROL.md §3.1, §6; HTTP.md §1, §3; still
  Draft). Up to 10 files, 25 MiB each and 50 MiB together, each named in the signed
  command by its SHA-256 (base64url), so a receiver cannot swap one. The receiver
  holds the bytes and serves them at `GET {control}/files/{sha256}` with the machine's
  token; the machine stops reading past the signed size, checks length and hash,
  writes them under `<cwd>/.sessionpipe/files/<session id>/` (self-ignoring
  `.gitignore`, no symlinks followed, mode 0600) and starts the session — of any
  harness it can start — with the text plus the list of relative paths. Any file
  problem acks `failed` with code `file` and starts nothing. `files` on another kind,
  `null` or empty is `bad_fields`. Schemas: `control-command` gains `files`;
  `control-pair` and `control-hello` gain `files: true` (the machine takes attachments
  — a receiver should not send files to one that doesn't say so); the well-known
  `control` object may say `files: true`. 24 new control vectors (`66`–`89`).
- `turn.ended` gains `background` (tier 0, optional): how much background work the
  harness reports still in flight as the turn ends, each piece of which wakes the session
  when it finishes. A receiver may keep such a session under working instead of reading
  "done" (PROTOCOL.md). Claude Code fills it from the Stop hook's `background_tasks`,
  in every Stop recorded since 2.1.258. Conformance: `harness/claude-code/stop-background.json`
  (a background shell command running, 2.1.305); `stop.json` now pins `background: 1` (an
  Explore subagent still running) and `stop-2.json` `background: 0`. Additive: a receiver
  that doesn't know the field keeps it and carries on.
- Protocol: `session.launched_by` (PROTOCOL.md §3): the session that started this one, as
  `<harness>:<id>`, read from `SESSIONPIPE_LAUNCHED_BY` in the hook's environment. A script
  that runs `claude -p` for a session sets it, and a receiver can group the run under its
  launcher without reading it as a subagent, which `parent_id` would say (#65). A subagent
  keeps its `parent_id` and gets no `launched_by`; a launcher that starts a further session
  resets the variable, so it names the nearest launcher only.
- Control: `start` is no longer Claude Code's alone (CONTROL.md §6; asked for by the
  maintainer on 2026-10-04, so the spec change ships with its code). A `start` names a
  session of any harness the machine can drive headlessly — the reference daemon drives
  Claude Code, Codex (`codex exec`) and Antigravity (`agy -p`) and lists them in its
  hello — and `resume` covers Codex (`codex exec resume`) and Antigravity
  (`agy --conversation`). New rule: a machine whose harness names its own sessions
  keeps the command's id as the session's name in everything it reports (acks, later
  prompts, hook events); a prompt for a session whose start is still running waits for
  it. Conformance: `control-vectors/53-start-other-harness.json` (a Codex start) now
  verifies, where it was refused `bad_fields`. ADAPTERS.md: Codex and Antigravity gain a
  Control row, and Antigravity's row no longer lists the PreToolUse hook 0.6.1 removed.
- Control rewritten before anyone implemented it (CONTROL.md, HTTP.md §3; still
  Draft, milestone M6). The old draft let whoever held a bearer token prompt an agent
  and answer "allow"; now:
  - **Every command is signed** on the person's device — a passkey vouches for a day
    key, the day key signs each command (or one passkey confirm per command) — and
    **verified on the machine** against keys enrolled at its own terminal
    (`sessionpipe control pair`). A receiver carries and may pre-check; it can never
    forge. One verifier, `sessionpipe-core/control` (WebCrypto only), and
    `conformance/control-vectors/` (60 vectors) that every verifier must agree with.
  - **One poll per machine**, not per session: `GET {control}?machine=…` with the
    machine's own token, minted at pairing; the command names its session
    (`<harness>:<id>`). New: `POST {control}/pair`, `GET {control}/pair?code=`,
    `POST {control}/hello`, `POST {control}/off`.
  - **Six delivery modes**, first fit wins (`sdk`, `waiter`, `turn`, `api`, `resume`,
    `fork`), acked with the mode used; new kind `start`; new outcome `refused` with a
    `code`; acks may carry `reply`.
  - Schemas: `control.json` is now the carried envelope (`cmd`, `csig`, `grant`,
    `confirm`); new `control-command`, `control-grant`, `control-key`, `control-pair`,
    `control-pair-started`, `control-paired`, `control-hello`; `control-ack` gains
    `mode`, `session`, `code`, `reply`; the well-known `control` object gains
    `signing` (`rp_id`, `algs`).
  - Limits fixed where the two implementations it came from disagreed: nonce 22–64
    base64url, text ≤ 4 000 characters, command ≤ 12 000, session id per adapter.
- Fixed contradictions: a sink never carries `control` (the CLI's `sink add --control`
  is now ignored with a pointer to `control pair`); sessionpipe.org no longer says
  spacesheep runs "with control"; CONTROL.md's six delivery scenarios all exist
  (09–14); Gemini CLI's AfterAgent is in the Stop-block list; attention ids are
  specified as the adapters mint them; a retried batch reuses its `webhook-id`
  (derived from the events' ids); SECURITY.md and CONTRIBUTING.md name the one
  exception to "the hook never alters the harness" — a verified control message — and
  exempt the PermissionRequest wait from the 150 ms hook budget.
- Protocol v1 (Draft): envelope, event vocabulary, tiers 0–3, HTTP binding,
  discovery, control channel. See `spec/`.
