Changelog
Every release of the four packages (they share one version number), newest first. Built from the repository's changesets each time the site deploys, so this page cannot drift from what shipped. Each entry links the commit that made it. Subscribe with the Atom feed.
0.15.1 next
Merged to main and waiting for the release. The version follows from the largest bump below.
- patch
conformance: a fixture run reads only the folder it was given, and holds an event's data to the fixture exactly
Details
Four changes to
sessionpipe-conformance, from a security scan of the repository's tooling.An event's
datanow 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.argvthat is not a list of strings, anexpectentry that is not an object, anenvthat is not a map of strings) is aFAILrow 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.
0.15.0
- minor
cli: output a person can read at a glance, and one place that says whether this computer is fine
Details
sessionpipe doctornow 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 statusis the same in a few lines.--jsonkeeps 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
healthrequest), 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 statusis a page at a terminal, and exactly the JSON it always printed when piped or given--json.--helpis one page of every command in groups, each with what it is for in a line that fits 80 columns; a command's own--helpfolds its usage between options.Colour comes from one small module with no dependency: on at a terminal, off there with
--no-color,NO_COLORorTERM=dumb, on there withFORCE_COLORwhateverNO_COLORsays. 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 doctorexits 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 --jsonexits 0 whatever it finds, as before.statusexits 0, as before.- A command that fails. Where stderr is a terminal it prints two lines:
errorwith what is wrong, andtrywith 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 pairwhen 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: 123456orenter: 123 456,✓ Paired with …,Daemon: …. At a terminal the link and the digits stand each on a line of their own (open:,check:orenter:), and the last line startsBackground service:.- Lines that are worded anew, at a terminal and in a pipe.
doctor,statusand--helpare new pages, andcontrol modesets each mode out on its own line.installsaysThis computer: <name>where it saidMachine: <name>,No coding agent found on this computerwhere it saidNo supported harness found on this machine, andNothing leaves this computer yetwhere it saidthis machine.ask on,ask offandpool onsayBackground service: …where they saidDaemon: ….Renamed the background service,The background service uses the new list within secondsandThe background service uses the new setting within secondssaidthe control daemon.sink listandpoolkeep their old lines in a pipe and get a layout that fits at a terminal. The lineask <agent>writes to stderr when the service goes away is unchanged. --no-coloris 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_inis now what the running background service sees, where it can be asked, and this shell's view only where it can't.warningskeeps 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(orthis computer's disk was full <when> ago, by the background service's log), andthe pool has no account with room left: Claude Code sessions here are being refused.update.offkeeps 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/servicegainsserviceState: whether launchd or systemd has a service now, without changing anything. - minor
update: one command leaves the whole computer current
Details
sessionpipe updateused 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 typelaunchctl 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_CLAUDEadded 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 updateagain when npm can't be reached (what needs no download is still done, and nothing is ever moved back to an older release); asudo npm install … && sessionpipe updateline for a prefix this user can't write, with the prefix quoted (on Windows:sessionpipe updatefrom a terminal opened as administrator);npx sessionpipe@latest updatefor a copy npm didn't install;sessionpipe control runwhere 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 updatechanges 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.
updategives 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,updatereplaces 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, andupdatesays so and exits 3. Into a pipe a failedupdateis one line that endsNext: <command>.The daemon's own daily update takes the same steps (
installEverywhere): its own copy, thesessionpipeon 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 hasdist/cli.jsand a package.json that says it is sessionpipe.sessionpipe doctorsees a split between the three, says which, and namessessionpipe updateas the one fix; it no longer sends anyone tosessionpipe installfrom 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,doctorandupdateboth namesessionpipe control run.Also: the registry asked for
latestis the onenpm_config_registrynames, 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_*orHOMEpoint 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:serviceStatealso answers the program a service file names (serviceArgv), and saysmanager: nullwhere launchd or systemd can't be asked from this process;serviceEnvironmentreads back the environment a service file gives;installServicerestarts 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 (ServiceRefusedsays which).
0.14.2
- patch
outbox: a line that is not valid UTF-8 no longer costs a later event
Details
Outbox.readcounted 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 tailand 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.
0.14.1
- patch
pool: a request over the body cap gets the 413 that says why, not a connection reset
Details
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.
0.14.0
- minor
Grok Build: sessions report under their own name,
sessionpipe install --grok-buildDetails
- A new adapter,
grok-build, for xAI's Grok Build CLI.sessionpipe installwrites 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'supdates.jsonl. Title, model, folder and times come from the session'ssummary.json.backfilllists 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,StopFailureandPreCompactare mapped from Grok's documentation and still wait for a recorded payload.
- A new adapter,
- patch
ask: a level or a model on a new ACP session is refused, and
ask statuslists ACP agentsDetails
sessionpipe ask grok-build "…" --permission readstarted 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.--modelthe same.sessionpipe ask statuslisted 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.
0.13.8
- patch
A poll that never ends no longer holds the control daemon for ever, unseen.
Details
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 statusreading "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") andsessionpipe doctorshows 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.
0.13.7
- patch
Pairing a Mac again no longer leaves it with no daemon.
Details
Installing the service over one that is running (
sessionpipe control pairon 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 ranlaunchctl bootstrapby 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 runlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.sessionpipe.control.plist.
0.13.6
- patch
The
PermissionRequesthook names a prompt by its tool call's id where Claude Code's payload carries one, as theattention.neededit reports already did.Details
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.
- patch
A permission question can be answered from the page while a subagent is running.
Details
The daemon holds a
PermissionRequesthook 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 refusedno_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 ackeddeliveredand changes nothing.
0.13.5
- 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.
Details
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
takenmeanwhile) 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. - patch
A run that was asked for a copy and ran as the original instead no longer frees the original when it ends.
Details
A Claude Code that takes no notice of
--fork-sessionruns 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. - patch
An ACP agent that goes away with a message to it still unwritten no longer raises an error nobody catches.
Details
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 EPIPEin the daemon. The failed write is now heard like the agent's exit is: whoever is waiting for the answer gets "stopped taking input: …" (or its exit), and the session is closed so the next message opens another. - patch
A turn that was stopped no longer reads as answered with "No response requested."
Details
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. - patch
A Claude Code session is found only under the id its transcript is filed as.
Details
On a disk that ignores case (macOS, Windows) a command naming
claude-code:8C132906-…opened the transcript of8c132906-…, 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 refusedno_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. - patch
A Stop for a session of one harness no longer stops another harness's session that has the same id.
Details
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 ofcodex:<id>, and a Stop forcodex:<id>interrupted a Claude Code session it held under that id. Both are found by the whole session ref now. - patch
A Stop that arrives together with the turn it was pressed for stops it.
Details
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.
- patch
A message for a session whose copy is in the middle of a turn is no longer run into that turn.
Details
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=resumein 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. - 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.
Details
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 --resumebeside 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 ackedunsupportedwith 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.
0.13.4
- patch
A copy that is stopped before it has written anything is not recorded as the session's copy.
Details
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.
- patch
A copy of a session that fails no longer makes the machine treat the original as free.
Details
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. - patch
A message to a session whose headless run has just exited now resumes it in place at once.
Details
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 -pat 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:detailopens "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". - patch
A Stop finds the turn under either of a session's two names.
Details
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".
- 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.
Details
A message stopped then is acked
deliveredwith "(The turn was cancelled.)", and a new sessionfailedwith codecancelledand nothing of it left in the project. - patch
A session whose run the daemon ended is no longer left mid-turn.
Details
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".
- patch
Nothing of a message's
idreaches a session unless it is a whole ULID.Details
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:
messageMarkinsessionpipe-core/controlanswers null for anything else, andframeMarkedthen 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'ssessionpipe channeland 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 tosessionpipe wait, it is ackeddeliveredwith noreplywhen the session's turn ends. Receivers that follow HTTP.md already send ULIDs and see no change. - patch
When the daemon can't read a Claude Code's options, the refusal says why in the command's own words:
claude --helpfailed: 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.Details
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. - patch
Closing an ACP agent that has already exited signals nothing.
Details
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.
- patch
A headless run that reaches its 30-minute cap no longer signals its process group ten seconds after the child has exited.
Details
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.
- 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'susertext 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: …).Details
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/controlexportsmessageMark(null for an id that is not a ULID),frameMarked,copyNote(why)andunframe, andframeAsktakes the mark as a third argument. - patch
Two messages can no longer run in one copy's transcript at the same time.
Details
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.
- patch
Messages for one session run one at a time in the order they were sent.
Details
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. - 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 --resumeuse: "no Claude Code session 88749ed7 on this machine".Details
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. - 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.
Details
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 isdeliveredwithout areply, and no longer carries a file path indetailwhen the transcript can't be read. - patch
A Stop reaches a turn from the moment its message's turn comes, not only once its process is running.
Details
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 ackedtakenand thendelivered("stopped the turn this computer was running for that session"), a message's own ack isdeliveredwith "(The turn was cancelled.)", and a start that never began is ackedfailedwith codecancelledand "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. - patch
Stop (
cancel) now stops a turn the daemon itself is running.Details
A session started or resumed from a page runs as a
claude -p(orcodex exec,agy) child of the daemon, and a Stop for it used to be ackedunsupportedwhile 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 canceltakenand thendeliveredwith the turn's mode and "stopped the turn this computer was running for that session", and acks the stopped turn's own messagedeliveredwith 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 isunsupportedand 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 isexpired("nothing is running in that session as far as this computer can tell, so there was nothing to stop"). spec/CONTROL.md §6cancelsays so, with fixturesconformance/delivery/30–33. - 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
usertext like the notes written today.Details
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
usertext, as for any other ask. - patch
A turn in a Claude Code terminal session reaches a tier-2 sink when it ends, not when the next one does.
Details
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.endedwithreason: interruptwhere 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. - patch
A turn's
usertext is what the person wrote, and nothing the machine wrote around it.Details
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
with sessionpipe control, signed on the sender's device.)", "Message via (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, inturn.transcript, inturn.progressfrom 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: on this machine with sessionpipe ask. …)", the last paragraph of the turn's usertext however the task arrived), and the first line of a handoff's answer ("Answer from: (sessionpipe handoff, …):"). A message delivered into a terminal session at a Stop hook is now a turn of its own: its
usertext is the message, markedinjectedlike 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/controlexports the sentences (frame,frameStart,frameMarked,frameAsk,frameHandoff,IN_PLACE_ANSWER,copyNote,RE_ARM) anddeliveredWords, which takes off a text the ones that are not the message. - patch
The protocol's JSON Schemas (
schemas/v1/*.json) are generated fromproto/sessionpipe/v1/*.proto, the protocol's one definition (RFC 0001, step 1), byte-identical to what the Zod source produced.Details
The Zod file stays this release and is held equal to the proto by a test; nothing on the wire changes.
0.13.3
- patch
A
claudeinstalled under a node version manager is found by the daemon. launchd and systemd start the daemon with a bare PATH, so a Claude Code thatnpm install -ghad 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.Details
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 connectwrites the pairing shell's PATH and theclaudeit found (as SESSIONPIPE_CLAUDE) into the service file, so the daemon sees what the person's shell saw. When noclaudecan 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. - patch
A copy of an open session does what the message asks.
Details
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.
- 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 sameclaude auth statusthe daemon asks before a resume.Details
A session signed in through the Claude app runs fine in its window while the
claudethe daemon resumes with has no login of its own, and until now the first message found that out, failing with "runclaude 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.
0.13.2
- patch
A subagent's end now carries the
backgroundcount it restates (PROTOCOL.md §4, #153). 0.12.0 addedbackgroundtosubagent.endedso 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 onlyagent_idandagent_typefor that event, so the count never left the machine.Details
turn.ended.backgroundwas unaffected.
0.13.1
- 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.
Details
A command that would save it stops with an error naming the file and what is wrong with it;
install,connect,uninstallandcontrol pairstop before they change anything, andsink addbefore it asks the receiver anything. A config.json that is there but cannot be read is not written over either. - patch
A machine running workflows with hundreds of parallel subagents no longer runs a receiver out of requests (#208).
Details
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|beatpicks what a sink is sent:all(every tool call, as before) is the default; a receiver that meters requests asks formainwithtoolsin its well-known file, which the machine reads atsink addandconnectand again once a day;beatthins the main thread too. No receiver's view changes on this upgrade unless it asked.sink list,statusanddoctorshow each sink's setting;replaysays how many tool events it thinned and takes--tools allto send every one once. A permission prompt now carriesagent_idwhen 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. - patch
sessionpipe forgettakes 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.Details
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
forgetandreplaywait for its lock (up to 10 s) before they change anything; past that they change nothing, say so and exit 1. A cursor file holdingnullor a list reads as empty instead of stopping a drain. - patch
sessionpipe install,uninstallandbackfillrefuse a bare word (uninstall codex,uninstall --keep-state codex,install claude-code), say to pass--<harness>, and exit 2 without touching anything.Details
The word was ignored, so
uninstall codexunhooked every harness andinstall claude-codehooked every harness found. A word that is an option's value (install --machine codex) is read as before. - patch
sessionpipe installexits 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.Details
It exited 0, so a script couldn't tell. One harness hooked is still exit 0.
- 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, andinstallgoes on to the next harness, so Codex and Gemini CLI are still hooked.Details
Before, the first such file ended the whole command with
EACCES: permission deniedand 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,createdincluded, whichuninstallneeds.uninstallreports 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". - 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.Details
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.
- patch
pii@1 keeps an ISO date:
released 2026-10-31stays as written instead of becomingreleased [phone], and2026-10-31 12:00no longer becomes[phone]:00.Details
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 fileskeeps 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 in2026-10-31 415-555-0132or2026-10-31 0132:, is read as before (a(,+or,between them starts a new number, and the date stays). - patch
A sink added with
--secretand 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, whichconnectdoes 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 secretssaid it couldn't be read, andsecrets move fileleft it there.Details
Each item the store holds is read now, so a sink an older release left stuck comes back the same way, and
secrets move filebrings 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 (doctorsays so) instead of its events waiting silently; a locked store still leaves them waiting.sink addwith--tokenon such a sink keeps the secret its store holds, instead of dropping it. - 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.
Details
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.
- patch
--tiertakes a whole number:sink add,install --sink,connect,control pairandtailrefuse--tierwith 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.Details
The other
sink,installandcontrolsubcommands refuse such a--tiertoo, where they ignored it.connect --tier ""used to fall back to the default tier andsink add --tier ""stored 0; both are refused now. Until nowsink addstored--tier oneasnull(and--tier 1.5as 1.5, which no event can carry) and printed "tier NaN sends: undefined", and stored--tierwith 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'smax_tiertoo: 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 carriedtier: null, which no receiver takes. - patch
sessionpipe updatewith a word other thanofforon(update OFF,update of) prints its usage and exits 2.Details
It installed the latest release instead, so someone switching updates off got an upgrade and a daemon restart.
- patch
On Windows,
status,doctor,secretsandconnectsay where a key lives (a file under your profile, not "0600": modes mean nothing there) and that updates run only whilesessionpipe control runis running, since no service runs the daemon on Windows yet. - 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.
- 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.Details
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.
- patch
sessionpipe-conformanceruns a single fixture file as well as a folder, takesHTTPS://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.jsonfixtures (exit 2), instead of a stack trace.Details
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.
0.13.0
- 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).Details
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, orreadwhen 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; areply_toit can't place when the command arrives makes the command no handoff at all (nothing relayed, no event).ask onfrom 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 carriesSESSIONPIPE_ASKED_BYandSESSIONPIPE_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 saydelegated_byand the asker's saydelegation.startedanddelegation.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;--jsonprints{ask_id, session, reply, ms}. Apromptor astartwithreply_tois a handoff: after its finaldeliveredack the daemon delivers the reply into that session as a message (§6 Handoff), and the events onreply_tosay whether it wasdelivered,unsupportedorfailed. The pairing request and every hello sayaskandhandoff: true.sessionpipe ask on|off|status:onwritesask: trueinto control.json and runs the daemon as a service even with no receiver paired (control runserves the socket alone unless asking is off too;control offkeeps the daemon when asking was turned on by hand);offsets it false and removes the service when no receiver remains;statusprints the switch, the daemon, the agents it can ask and the folders.sessionpipe ask setup [--remove]writes a marked section into every Claude CodeCLAUDE.mdand~/.codex/AGENTS.mdso a session knows the command exists and must never use it to send its own reply; hooks still inject nothing. - minor
sessionpipe channel: a Claude Code channel, so a message reaches a live session in its own thread.Details
Claude Code runs it as one session's MCP server (
claude mcp add --scope user sessionpipe -- sessionpipe channel, thenclaude --dangerously-load-development-channels server:sessionpipewhile 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 anotifications/claude/channel: the session takes it in its own permission mode, as the person's own words, and shows it as a message fromsessionpipein 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. - patch
Control: a
claude --helpthat couldn't be read is never taken for a Claude Code with no permission modes (#152).Details
detectCapsmarks a run that times out, or fails without listing--permission-mode, asunread(and takes a timeout, 30 s by default);modeFlagsthen refuses every level butinherit, saying why ("this machine couldn't read its Claude Code's options (claude --helptimed out after 30 s); try again in a minute"), where it used to refuseautoas a mode that Claude Code lacks and to runsafewith no permission flag at all. The daemon asks an unread--helpagain 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
--helpis unread, the hello offers no Claude Code start at all (an offeredsafewas taken, and then run with no permission flag), and a start is refused before it is taken, never run with an empty argv.
0.12.0
- minor
A session waiting on its own background work no longer reads idle on a receiver until the work ends (#153).
Details
turn.ended.backgroundleaves out sessionpipe's own waiter (sessionpipe wait, which ends only when a message arrives, and so held every reachable session at "working"),subagent.endedcarriesbackground, 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 nextturn.endedorsession.ended, not until the next event, which was always the workflow's own. Fixtures:harness/claude-code/stop-waiter(recorded on 2.1.312) andsubagent-stop. - 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.
Details
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. - patch
Windows: a harness installed with
npm install -gis a.cmdshim (claude.cmd,codex.cmd,gemini.cmd), which node refuses to spawn without a shell (EINVAL, since 20.12).Details
doctorandconnectdied withspawn EINVALon such a machine,claude --helpsilently 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.
0.11.0
- minor
Session names from claude.ai, off until the person turns them on (
sessionpipe titles on, config.jsonremote_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.Details
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 inconformance/remote/claude-code/), a minute apart until it has one, then every half hour, within 3 s, and sends it assession.titlewithtitle_source: "harness"; acustomtitle still wins.titles onandtitles refresh [--days N]also ask about the last 30 days' sessions and send each one already sent under another name again assession.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 toapi.anthropic.comalone, refuses redirects, never refreshes an expiring token and never writes it anywhere. Adapters gain an optionalremoteFactsandBackfillRow.transcript;Configgainsremote_titles. spec/ADAPTERS.md, spec/PRIVACY.md §3 and thesession.backfillrow of spec/PROTOCOL.md say so. - minor
limits.observedreaches tier-1 sinks: after a Codex turn when its rollout holds new numbers, and from Claude Code's status line withinstall --status-line(orconnect --status-line;--no-status-linetakes it away; remembered in the config).Details
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. - 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.
Details
sessionpipe pool add --importcopies this machine's Claude Code logins into the pool (--loginsigns another account in: a URL, then the code pasted to--code -),pool onwrites theenvblock into every Claude Codesettings.jsonand installs the daemon's service if it is missing,poollists the accounts in order with the room each has left,order,enable,disable,removeandrefreshshape the roster, andpool offremoves exactly whatonwrote. 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-1limits.observedwithaccount_idon that session, through the outbox like a hook's. Every hello carries the pool (poolobject), a receiver's signedpoolcommand drives it (on,off,remove,order,disableandlogin.completeneed 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, andcontrol offkeeps its service while the pool is on;sessionpipe uninstallrunspool offfirst. Nothing of this happens until you runpool addorpool onyourself. - minor
The account pool's contract (CONTROL.md §2
pool, §6pool; PRIVACY.md §3): a hello MAY carrypool(on,harness,accountsin the machine's order withstatus,current,until,limited_byand the idle poll'swindows; present in every hello of a daemon that knows the pool,on: falseand no accounts when nothing is set up), and a new signed kindpoolon the pseudo-sessionsessionpipe:pool(pool_action: on, off, enable, disable, remove, order, refresh, login.begin, login.complete;pool_account,pool_orderof at most 20,pool_codeof one line ≤ 512 characters), verified likekey.add: each action takes exactly its own fields (poolProblem,POOL_SESSION,POOL_ACTIONS).Details
limits.observedgainsaccount_id, the account the numbers belong to when a pooled session was served by another than its own; tier 1 carries it andpii@1drops it as it dropssession.account_id. Schemas:control-command,control-hello,event-data; control vectors 137–164. - patch
Codex's config.toml keeps its CRLF line ends through
installanduninstall: the notify line goes in with the file's own line end, anduninstallleaves the file byte-identical, as it already did for an LF file. - patch
install,statusanddoctorread Gemini CLI's version on Windows, where the CLI is npm'sgemini.cmdshim; before, every Windows install said "could not rungemini --version" and the 0.46 check never happened. - 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
installhooks it instead of reporting "not plain JSON; not touched", anduninstallleaves it byte-identical.Details
The harnesses read such a file themselves.
- patch
sink addandsink removerewrite the hooks when a sink crosses tier 0, asinstall's own message says they will; before, the hooks stayed the lean set,statusreadstale, anddoctorblamed an old node or script path.Details
doctornow says which set the hooks are and why that no longer matches, when that is the cause. - patch
sessionpipe tailnames the outbox with the platform's own separator:~\AppData\Local\sessionpipe\outboxon Windows, where it read…sessionpipe/outbox.
0.10.0
- minor
Control can start, prompt and cancel any agent that speaks the Agent Client Protocol (CONTROL.md §6,
apimode; ADAPTERS.md "Agents driven through ACP").Details
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 withsession/loadwhere 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 whereSESSIONPIPE_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 theirmodesincludeapiwhenever an ACP agent is listed. - minor
A Claude Code home that runs on another API now has an account.
Details
Such a home has no claude.ai login, and its
settings.jsonsetsenv.ANTHROPIC_BASE_URL(Kimi K3 on a Modal endpoint, GLM on Z.ai). Its events carryaccount_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, inaccounts[].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. - minor
Control: device keys (CONTROL.md §2, §3.2).
Details
sessionpipe-core/controlverifies a device key's signature ({cred, sig}, raw r‖s oversessionpipe.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;keyUsablecloses key fields and checks a device key's rules (type: "device",alg -7,store: "hardware",uv: "each-use", id = SHA-256 of the SPKI);verifyCommandrefuses akey.addwhose key could never be used (bad_fields).verifyAssertiontakes the purpose. New exportsDEVICE_CONTEXT,deviceMessage,deviceKeyId,KEY_TYPES,DEVICE_SIG_FIELDS,DeviceSignature,DeviceGrant. The daemon advertiseskey_types: ["passkey","device"]when pairing and in every hello, stores a device key whole (from a pairing or akey.add), andcontrol keysshows each key's kind. 26 new shared vectors (111–136). - minor
Control: a third machine mode,
inherit, so sessions you message run the way your own sessions on that machine run.Details
sessionpipe control mode inherit(or--mode inheritoncontrol pairandconnect, 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 itsconfig.toml'sapproval_policyandsandbox_modesay, 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:autoran each messaged session in Claude Code's default mode, which refused every command that needed approval, andsaferefuses them by design. Schemas:control-pairandcontrol-hellomodegainsinherit. - minor
Control: a machine runs in
inheritmode only with receivers that know it.Details
A receiver that predates
inheritshows 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 withcontrol.inherit: truein its well-known file (HTTP.md §1, CONTROL.md §2), andsessionpipe control pair --mode inherit(andconnect --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 inheritchecks 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-knowncontrol.inherit(optional boolean). Conformance:delivery/23-control-inherit. - 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).
Details
- Closed command field set. Each command kind carries only its own fields; any other field inside the signed
cmdis refusedbad_fields, by the verifier and on the machine (0.9.0 ignored it). Keep your own data besidecmd, 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 asinherit, 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 sayscontrol.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/pairand/hello(conformancedelivery/23).- Refusal code
modeis nowpermission, plusrefusedmodelandfailedmodel. Map both codes; a 0.9.0 machine still saysmode. - Hello:
start(per harness:permissionsamongread,safe,edits,auto, capped by the mode; the defaultpermission, which may beinherit;models,any_model),prompt_files: true, and nohome: a start'scwdmay be~or~/…instead.harnesseslists at most 20, andmodesincludeapiwhen an ACP agent is listed. - Acks: a start's
takenanddeliveredcarryapplied(permissionmay beinherit). Showapplied, never what was asked. - Events:
session.attended,turn.ended.background,turn.started.submitted_byandinjected,session.launched_by, andaccount_idapi-<16 hex>for a Claude Code home on another API, named inaccounts[]bynamewith noemail. All optional, tier 0. - ACP agents (gemini-cli, opencode, goose, cursor, copilot-cli, droid and more) appear in
harnessesand in acksessionrefs, acked in modeapi; 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}(plusstrin a grant) where an assertion would stand; a 0.10.0 machine sayskey_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 akey.add'skey, pass a{cred, sig}grant, confirm or pairing proof through untouched, and saycontrol.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 akey.adda trusted key signed. A receiver that doesn't know them keeps working, passkeys only. - While machines run 0.9.0: send
permissionormodelonly where the last hello'sstartadvertises them (0.9.0 ignores them silently);fileson apromptonly withprompt_files: true(0.9.0 refuses them); a~folder only to a machine whose hello hasstart; neverinheritas a command's level; a device key only for a machine whose last hello listsdeviceinkey_types(0.9.0 would store it as a passkey and refuse its signatures); read an absentattended,background,submitted_by,injectedorlaunched_byas unknown, never as false, 0 or a person.
- Closed command field set. Each command kind carries only its own fields; any other field inside the signed
- minor
A control
promptcan carryfiles, as astartcan (CONTROL.md §3.1, §6prompt), to a machine whose hello saysprompt_files: true.Details
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 ackedfailedwith codefileand the message is not delivered. A receiver no longer needs to put a link to an image into the message's text. - minor
session.attendedsays 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, neverfalse).Details
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 says0, a Remote Control session included, and aclaude -pstarted inside one inheritsCLAUDE_CODE_ENVIRONMENT_KIND=bridge, so the link is what tells them apart. The hook carriesCLAUDE_CODE_SESSION_ATTENDEDandSESSIONPIPE_CONTROL_JOBinto the job, and the worker takes those andCLAUDE_CODE_HOST_SESSION_IDfrom 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. - minor
session.launched_by: the session that started this one, as<harness>:<id>, read fromSESSIONPIPE_LAUNCHED_BYin the hook's environment (PROTOCOL.md §3).Details
A script that runs
claude -pfor a session sets it, and a receiver can group the run under its launcher without reading it as a subagent. A subagent keeps itsparent_idand gets nolaunched_by. - minor
A control
startcan 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").Details
permissionis one of four levels,read,safe,editsandauto, with nothing aboveauto;modelis the harness's own id, passed as one argument. The machine'smodeis the ceiling, and a start above it, or at a level the harness has no flag for, is refusedpermissionbefore anything is taken or run (the daemon's oldmodecode). On a machine ininheritmode 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 onlyreadorsafe, since sessionpipe can't rank the person's own default;inheritis 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 beinherit). A start'scwdmay 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 mostsafeon an inherit machine for a session that named a level). Thetakenanddeliveredacks of a start carryapplied(itspermissionmay beinherit); a model the harness rejects endsfailedwith codemodel. 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 statusshows the options per harness. A start whose text begins with a dash no longer reachesclaude -pas a flag. - minor
turn.endedcarriesbackground: how much background work the harness reports still in flight as the turn ends, each piece of which wakes the session when it finishes.Details
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. - minor
turn.startedcan 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 fromsession.started.source) andinjected(truewhen the prompt text is a block the harness wrote rather than words a person typed).Details
Claude Code fills
submitted_byfromUserPromptSubmit.sourcewhen a payload carries that key — its hook schema documentsuser·sdk·system·loop_wakeup·schedule_wakeup·poll_eventand says a payload may omit it while the field rolls out, and no build through 2.1.304 sends it — and setsinjectedfrom 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: truesays only that the prompt is not the person's words, never that no person caused the turn: a Talk orsessionpipe waitdelivery 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 tailprints both. Codex, Gemini CLI, Antigravity and Devin leave both absent. - 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.Details
sessionpipe-core/service: the launchd agent and the systemd user unit for any process (serviceFiles,installServiceanduninstallService, each with a dry run that answers the files and commands and touches nothing, andensureLinger).sessionpipe-core/version:compareVersions,isNewer,parseVersion,UPDATE_EXITandsupervise, 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'sExecStartquotes every argument.
0.9.0
- minor
Control daemon: a
startmay bring files, for every harness it can start (Claude Code, Codex, Antigravity).Details
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 acksfailedwith codefileand starts nothing. Pair and hello now sayfiles: true. - minor
sessionpipe control folders [add|remove <dir>…]: change the folders that sessions you message may run in, at the machine, without pairing again.Details
Before this, the folders were set only by
control pair --folder. On 2026-10-13 a Kimi session in~/claude-kimion a machine paired with~/claudewas 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. - minor
A
devinadapter: Devin CLI (Cognition) sessions are reported like any other harness.Details
sessionpipe install(or--devin) writes the eight event names Devin accepts into thehookskey of its user config file; the payload maps to the protocol's events, the folder comes fromDEVIN_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 newreaders/sqlite.tsthat works on Node 20 too (in-processnode:sqlite, else a short child process, else thesqlite3CLI, else no turns). Eight fixtures recorded from real runs on 3000.11.3. Also: theclaude-codeadapter now drops a payload with notranscript_pathand 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. - minor
Review fixes to the Devin adapter and two gaps it exposed.
Details
devin: a turn is now timed by the message's ownmetadata.created_at, not bymessage_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()reportsinactive, notcurrent, when the config file holds any event name Devin does not know — Devin then loads no hooks from that file at all, ours included — andinstall()says so too. Atool_responsethat is not an object no longer loses its output.facts()takes the hook's environment (new optional argument on theAdapterinterface), so a job swept by a worker another harness's hook spawned still reads the right config and session store.sessionpipe uninstalltakes the same--<harness>selectioninstallhas, removes the shared hook launcher only on a full uninstall, and refuses an all-harnesses uninstall whenSESSIONPIPE_CONFIG/SESSIONPIPE_STATEpoint away from this machine while other harnesses would still be read from$HOME(pass--allto mean it). The hook forwardsXDG_CONFIG_HOME,XDG_DATA_HOMEandAPPDATAin the job file, beside the per-harness variables already there. The control wait — up to 125 s on aPermissionRequest— now applies the same "this is not a Claude Code payload" test the worker does, so our entry in~/.claude/settings.jsonrun 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
sqlite3binary wherenode:sqliteis absent, so they run on the Node 20 floor — where thecliengine 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;inlineParamsand the adapter's own chain query are covered. - minor
turn.progress(tier 2): a turn still running goes out as it runs, not only when it ends.Details
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.transcriptwill carry. Over 20 000 characters the reply keeps its end. A 21-minute turn used to show nothing on its receiver for 21 minutes. - 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).
Details
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.
- patch
An Antigravity session in a git checkout now opens its own conversation from its link, not Antigravity's home.
Details
Antigravity keeps a git checkout's folder under
gitFolderin~/.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). - patch
A busy session's events no longer wait 6 s and go back to the queue behind its own lock.
Details
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).
- patch
Claude Code: a message the person sends while a turn is running now reaches
turn.transcriptandturn.progress.Details
Claude Code records it as a
queued_commandattachment rather than auserrecord, so the transcript reader dropped it from tier 2. - 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.
Details
Output and exit codes are unchanged.
- patch
The usage table behind
--helpis exported (usageTable), so sessionpipe.org's docs page prints the same usage lines the CLI does;--helpoutput is unchanged.Details
The README's closing links sit at its end again, after "Help and status JSON".
- patch
Skip the parent's replayed history in forked Codex rollouts when reading session facts and transcripts.
Details
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.
- patch
Codex: only the person's own threads become sessions.
Details
- An ephemeral thread is no longer reported: its hooks carry
transcript_path: nulland 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 doescodex 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_idwith 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
expectis a payload the adapter must report nothing for.
- An ephemeral thread is no longer reported: its hooks carry
- patch
The control daemon retries pending events for every non-paused sink once a minute, including sinks whose token is in config.json.
Details
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.
- patch
sessionpipe control pairnames a first pairing the way the hooks name the machine:machinefrom config.json, else the hostname without its.local,.lanor.homesuffix.Details
On macOS the daemon used to register as
<name>.localwhile events said<name>, so a receiver showed the same machine twice and could not route approvals to it.--nameand an existing pairing's name still win. - 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.
Details
Show polling state, time since the last successful poll, and the last control error in status and doctor.
- patch
The control daemon's socket is now mode 0600, as spec/CONTROL.md §8 says.
Details
It used to take the umask (0755 on a default macOS), so only its 0700 parent kept other users out.
- patch
The hook and worker entries run only when invoked as the script, so both can be tested in process.
Details
What they do, print and exit with is unchanged; the hook still imports node builtins only and its timing is the same.
- patch
Ignore Claude Code's lone SessionEnd hooks (for example, from
claude mcp list) when there is no outbox, stored facts or existing transcript.Details
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.
- patch
No hook event is lost to a worker's 15 s deadline, and none waits for an unrelated hook.
Details
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.
- patch
A machine under a flood of hook events no longer floods its sinks.
Details
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. - patch
control pair: a code nobody approved before it expired is renewed in place, with fresh digits, for up to an hour.Details
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.
- patch
turn.progress: a message absorbed by a running turn, and text the person never typed, no longer open a turn of their own.Details
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.
isInjectedalso recognises a wrapper tag with attributes (<cross-session-message from="…">…</cross-session-message>). - patch
sessionpipe connect --machine NAME --rename-machine(andinstall) now renames a paired control daemon too, so the hooks and the daemon stay one machine on a receiver.Details
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.
- patch
After a 429, the HTTP sink waits as
retry-aftersays when the header is an HTTP date (RFC 9110), not only when it is a number of seconds.Details
Before, a date fell back to the 2 s backoff ladder.
- patch
A repeat
installorconnecton 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.Details
Before, a Mac paired before 0.9.0 (daemon
<host>.local, events<host>) had its events switch to<host>.localon 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. - 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.Details
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. - patch
Refuse connect/install downgrades of newer global packages or persistent hook copies, including newer prereleases, unless --allow-downgrade is explicit.
Details
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.
- 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.Details
Such a stdin made
fromHookthrow 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. - 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.
Details
No behaviour changes; the public API is unchanged.
- patch
Write an
ssh://git@host/owner/repo.gitorigin ashost/owner/repoinsession.repo, the same as thegit@host:owner/repo.gitform of that remote.Details
Before, the two spellings of one repository reached a receiver as two different repos.
- patch
Internal: the home directory is read when a path is asked for, not when the package loads.
Details
A process whose HOME changes after import (a test with a sandbox home) gets the new one; no user-facing change.
- 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.
Details
A 10 MB session drained in batches of 50 decoded 55 MB.
- 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).
Details
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.
- 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 ofsend(). - patch
Stop the transcript cursor before a last line the harness is still writing.
Details
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.
- patch
The CLI entry runs only when invoked as the script, so the package can be imported by tests.
Details
Output is unchanged.
0.8.0
- 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 labelsession.account_idwith the account's email instead of showing an id.Details
Events are unchanged:
account_idstays an id and no event carries an email.sessionpipe control pairandconnectsay which accounts they named. - patch
Handle
--helpand-hbefore dispatching every CLI command, including nested commands, so asking for usage cannot install hooks, queue work, or contact a receiver.Details
Implement
status --jsonwith doctor's names for shared facts plus pending session counts and queued jobs, preserving the human status output. - patch
Build on TypeScript 7 (
baseUrldropped from every tsconfig;pathsalready resolved relative to the file).Details
Dev-only: published output is unchanged.
0.7.0
- minor
Control: messages reach Codex and Antigravity sessions too.
Details
The daemon starts a new session with
codex exec --jsonoragy -p … --output-format stream-json, and resumes one withcodex exec resumeoragy --conversation, the way it drivesclaude -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 itsworkspace-writesandbox 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) anddrivableHarnesses; the verifier accepts astartfor any harness. - 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.
0.6.1
- patch
Antigravity: stop hooking PreToolUse.
Details
Antigravity reads a PreToolUse answer without a
decisionas 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 anaskwith a reason instead of a silent deny until it is restarted.
0.6.0
- minor
The control daemon updates itself.
Details
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'slatest; 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 withnpm install --global --ignore-scriptsinto 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 carrySESSIONPIPE_SERVICEandRestartForceExitStatus=75), and otherwise the old process stays as the new daemon's parent. Off withsessionpipe update off(update_check: false, now actually read),SESSIONPIPE_NO_UPDATE_CHECK, or when the copy isn't a writable global npm install.statusanddoctorshow whether it's on, the last check and the latest version.One daemon per machine:
control runno 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 updateinstalls 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. - 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…".
0.5.0
- 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.Details
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 uninstallor 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".connectanddoctorlist each account's state. sink addkeeps 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 pairrun from the home folder no longer allows all of it;control.jsonkeeps the machine's own caps across a write.
- Hooks run a launcher,
- patch
Run control pairing from the global install when invoked through npx, and use a stable Homebrew Node path for the daemon service.
Details
Existing service files are repaired by pairing again with the fixed CLI; install and update alone do not rewrite them.
0.4.2
- 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.
0.4.1
- patch
control pairsays 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.
0.4.0
- 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.Details
Five new conformance vectors.
- minor
sessionpipe control pairasks once, at the machine, whether sessions you message run in auto or safe mode (no more silent safe default), andsessionpipe control mode auto|safechanges it; the running daemon picks it up within seconds.Details
Message text may be 20 000 characters (command 48 000): a 4 000 cap silently cut real task briefs.
- minor
A receiver's well-known
control.session_toolsnames 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.Details
MCP server names only; built-in tools stay behind the person's approval rules.
- minor
Answers stream: a non-final
progressack carries the answer so far (whole, with a risingseq).Details
The daemon streams headless runs from Claude Code's
stream-jsonoutput (token by token) and in-place deliveries from the session's transcript (message by message); the final ack still carries the whole answer.
0.3.0
- minor
Open pairing:
sessionpipe control pair spacesheep.devneeds no key where the receiver declarescontrol.open_pairing.Details
One link, one passkey approval; the daemon proves itself on the status poll with a
poll_keyonly 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--tokenwith a character no key has (a pastedss_…placeholder) is refused in words.
0.2.0
- minor
The control daemon (M6):
sessionpipe control pair | status | keys | off | runandsessionpipe wait.Details
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,limitsin 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 logsback after N s. - 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.Details
The CLI ignores
sink add --control(control is per machine) and a retried batch reuses itswebhook-id.
0.1.1
- 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.
- patch
sink testsends a heartbeat and then itssession.forgotten, so a receiver keeps no phantom session from the test. - 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.endedwithreason: error;status/doctorsay when Gemini CLI won't run hooks (tools.enableHooks) and word the Codex review honestly;doctormeasures 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~/.localwhen npm's prefix is Homebrew's versioned Cellar; a barepass:is no longer a secret name; the outbox holds redacted strings at rest in 0600 files under 0700 directories. - patch
READMEs carry the published (unscoped) package names.
- patch
Published under unscoped names (sessionpipe-core, sessionpipe-receiver, sessionpipe-conformance) until the @sessionpipe npm org exists.
0.1.0
- 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.
Details
CLI: install, uninstall, sink, status, doctor, tail, backfill, forget, replay, update; the hook entry imports only node: builtins. Conformance: adapter fixture runner.
- minor
Protocol v1 draft: the Zod source of truth (
packages/core/schema/v1.ts) and the generatedschemas/v1/*.json; inferred runtime types.
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 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_idset) arrive as at most onetool.endedper session per 60 s, and never astool.started. The main thread's arrive as before.beat(opt-in) thins the main thread's the same way. Nothing buttool.*is thinned. - A thread's first
tool.endedafter itsattention.neededis always sent, so "the next event that proves the prompt was resolved" (attention.cleared) still arrives.attention.needednow carriesagent_idwhen a subagent asked. seqhas gaps where events were thinned; a gap is never a lost event (conformance/delivery/29-seq-gap.json). A receiver MUST NOT expect atool.endedfor everytool.startedor 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
mainand later needs every tool event saysall(or drops the field); the machine reads the file again within a day.replay --tools allre-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
askandhandoff(CONTROL.md §2).askis whether sessions on that machine may ask another agent (sessionpipe ask, §13: on unless the person turned it off);handoff: truesays astartor apromptmay carryreply_to. A machine that says neither is 0.12.0 or older: send it noreply_to.reply_toon astartor aprompt(CONTROL.md §3.1, §6 Handoff):<harness>:<id>of a session on the same machine, never the command's ownsession, inside the signed bytes. The command runs and is acked exactly as before, one final ack; after adeliveredack the machine delivers the reply intoreply_toas a message framedAnswer from <harness>:<short id> (sessionpipe handoff, asked via <receiver host>):, through its normal prompt routing, never wider thanreply_to's own mode. On any other kind, malformed, or naming the command's own session:bad_fields(vectors165–169). Areply_tothe 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 addstext, ≤ 200 chars) anddelegation.ended(ask_id,to?,ok,ms,error?,relayed?:delivered·unsupported·failed; tier 2 addsreply, ≤ 20 000 chars). They travel through the session's own outbox with its ownseq. For a handoff,delegation.startedcomes when the machine takes the command (tois the command's session) anddelegation.endedafter its final ack; a command whose final ack isn'tdeliveredrelays nothing, and itsdelegation.endedhasok: falseand norelayed. For an ask that starts a new Codex or ACP session,tois 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, fromSESSIONPIPE_ASKED_BYthe waylaunched_bycomes fromSESSIONPIPE_LAUNCHED_BY. Lineage across harnesses, never authority: join it withdelegation.*.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), betweenturnandapi. 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 listsinboxinmodeswhere the mode exists; an ack'smodeisinboxwhere 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 unknownmodeon a final ack that carries the reply anyway.failedwithcode: "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. Conformancedelivery/25.refusedwithcode: "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.detailnames the accounts by their directory name. Nothing ran.refusedwithcode: "copy_limit"(CONTROL.md §5, §6prompt). The machine has made the copies it makes in a day. A copy replays a whole conversation, so machines cap them;detailsays so in words, and at a cap of zero says the machine makes none.Ack
copy_of(CONTROL.md §6prompt, §10), aSessionRef: this answer came from a copy of the session the command named, not from it. Afork's ack carries the copy insessionand the original here, and itsdetailmay name the mode the copy's own turn ran in. A receiver shows whose answer the person is reading. Conformancedelivery/27.Event field
injected(PROTOCOL.md; optional, tier 2) onturn.transcriptandturn.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 theinboxmode reaches a session. The words are the person's; the arrival was not. It says of a turn's ask whatturn.started.injectedsays 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 channelis a Claude Code channel (its--channelsMCP 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 beforeinbox; its delivery is proven by the turn it causes, and a channel message the session began no turn for is ackedfailedwithcode: "held"like an inbox write (conformancedelivery/28). A receiver sees the reply as the session's own, ackedmode: "waiter"; a channel's ask isinjected.An
inboxanswer 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 ackedmode: "inbox".
Adopting 0.13.0 while machines still run 0.12.0:
- Offer a handoff (
reply_to) only for a machine whose latest hello sayshandoff: true; an older machine refuses the commandbad_fields, since its field set is closed. - Read an absent
askorhandoffas "not on this machine", an absentdelegated_byas unknown, and keep adelegation.*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/controland its vectors (165–169are 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, theturn.endedandsubagent.endedrows; issue #153). Theturn.endedrow said a receiver MAY show such a session as working "until its next event"; the next event was always the work's own (asubagent.startedseconds 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 nextturn.endedorsession.ended, and thatsubagent.*and a subagent's tool events are not the session waking. Two things from the sender help:subagent.endedMAY carrybackground, the count once that subagent is out of it (schemas/v1/event-data.json; fixtureharness/claude-code/subagent-stop), so a receiver sees the count fall to 0 before the next turn; andturn.ended.backgroundno longer counts sessionpipe's own waiter (sessionpipe wait, CONTROL.md §8; fixtureharness/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), eachidthe 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?andwindow_minutes?(only where the harness or its documentation says the length);plan?; andaccount_id?, the account the numbers belong to, spelled assession.account_idis. Each event is the whole set: a window it leaves out is not reported (Claude Code drops one once it resets), and a window whoseresets_athas passed has reset, so a receiver SHOULD NOT show its lastused_percentas current. The numbers belong to the account, not the session: a receiver SHOULD keep the newest per (harness,data.account_id, elsesession.account_id), or per machine when there is neither. A sender MUST setaccount_idwhen 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 owncodexbucket), Claude Code's status line where the person opted in withinstall --status-line(five_hour,seven_day,spend_limit;window_minutes300 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@1dropsdata.account_idas it dropssession.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; conformancedelivery/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, andaccounts(at most 20) in the machine's order, each withid(the same stringsession.account_idandlimits.observed.account_idcarry for it, so a receiver joins the three without a map),emailorlabelwhere known (they travel only here),enabled, astatus—ready,limited(withuntilwhere the machine knows when),error(sign in again),disabled—current: trueon the one serving new sessions now, and optionallywindows, the account's freshest numbers in the shape oflimits.observed.windows(from the idle usage poll, since an idle account's numbers ride on no session), andlimited_bywhen the status islimitedorerror: the window's id (seven_day), orrejectedorerrorfor the causes that are no window. A daemon that knows the pool sendspoolin every hello,on: falseandaccounts: []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 readswindowsas it readslimits.observedand MUST NOT send it on as an event; it is a hello's reading, for the receiver's own page. A hello withoutpoolis a daemon that predates the pool: a receiver MUST NOT offer pool controls for it. - The
poolcommand (CONTROL.md §3.1, §5, §6pool;schemas/v1/control-command.json; vectors137–164): a new signed kind on the pseudo-sessionsessionpipe:pool, the only kind besideskey.addthat 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_actionis required and closed —on,off,enable,disable,remove,order,refresh,login.begin,login.complete; any other value isbad_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 byenable,disable,removeandlogin.completeand optional onrefresh;pool_order(1–20 ids, no repeats, every account of the pool once) belongs toorderalone;pool_code(one line, ≤ 512 characters) tologin.completealone. Apool_*field on any other kind, or on apoolwhose action doesn't take it, isbad_fields. Fresh confirm:on,off,remove,order,disableandlogin.completeare verified by a fresh confirm (§3.2) or not at all; one that arrives on a day grant alone is refusedconfirm_requiredby the machine (vector164is the confirmed form).enable,refreshandlogin.begintake 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:deliveredwith nomodeanddetail= the machine's one-line summary of the pool, or forlogin.beginthe 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.refusedpool_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.beginorlogin.completewhile the pool is off).failedwithcodelogin(the vendor would not take the code) orpool(the vendor call arefreshorlogin.completeneeds did not go through);detailsays what, never a token.unsupportedwhen the daemon knows the kind but this machine cannot pool. A daemon that predates the kind refuses itunknown_kindat the verifier. No command adds a credential from the receiver's side:login.completecarries 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; withpool onorpool 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 aslimits.observed. - Session names from claude.ai (PROTOCOL.md, the
session.backfillrow; ADAPTERS.md Claude Code Facts; PRIVACY.md §3): withremote_titleson, a Claude Code session with a claude.ai link arrives assession.titlewithtitle_source: "harness", acustomtitle still winning, and a session already sent under another name is sent again assession.backfillwith the session's nextseq; a receiver applies its session block as it does any event's. No new field; the recorded answers are inconformance/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, Codexconfig.tomlline ends, Gemini CLI's version on Windows, hook files with a byte-order mark) changes nothing a receiver reads, except that a BOM-markedsettings.jsonon a home on another API now yields itsapi-<16 hex>session.account_idwhere it reported none.
Adopting 0.11.0 while machines still run 0.10.0:
- Keep
limits.observedas the account's, not the session's: the newest per (harness,data.account_id, elsesession.account_id), per machine when there is neither, and read a window whoseresets_athas 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. Keepemailandlabelfrom the hello; no event carries them. - Never send
poolto a machine whose last hello had nopool: an older daemon refuses itunknown_kind, and nothing happens. Gate per machine on its latest hello. - Ask for a fresh confirm before
on,off,remove,order,disableandlogin.complete, and name the machine it is for; a day grant alone is refusedconfirm_required. - Take
confirm_required,pool_account,pool_order,pool_off, andfailedloginandpool, as codes you may not have seen, and showdetail; 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/controland its vectors (137–164are 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,iatandnote. Any other field inside the signedcmdis refusedbad_fields, by the verifier and on the machine; 0.9.0 ignored it. A receiver keeps its own data (amatch, anorigin) besidecmd, 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,bypassPermissionsincluded, and Codex as itsconfig.tomlsays. A start that names no level runs that way. A receiver shows the machine asinherit, on its pairing page and wherever it shows a mode, and never assafeorauto; an unknown mode is unknown, not safe. The gate: a 0.10.0 machine pairs ininherit, or switches to it, only with receivers whose well-known file sayscontrol.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 acceptsmode: "inherit"in/pairand/hello(conformancedelivery/23).- The refusal code
modeis nowpermission(CONTROL.md §5), beside a newrefusedmodel, andfailedmodelwhen the harness rejects a start's model. A 0.9.0 machine still saysmode, so a receiver maps both;detailalways has the words. - New hello fields (CONTROL.md §2).
start: per harness, thepermissionsa start may name there (amongread,safe,edits,auto, already capped by the mode; on aninheritmachine at mostsafe), the defaultpermission(which may beinherit), andmodels/any_model.prompt_files: true: the machine takesfileson aprompt.files: trueis as in 0.9.0. There is nohome: no hello names the machine's home folder, which would tell every receiver the OS user name. Instead a start'scwdmay be~or~/…, the way events write a session's folder, and the machine reads it under its own home.harnesseslists at most 20 (Claude Code, Codex and Antigravity first), andmodesincludeapiwhenever an ACP agent is listed. - Acks carry
applied(CONTROL.md §6, §10): a start'stakenanddeliveredsay what it runs with,{"permission": …, "model"?: …}, wherepermissionmay beinherit. A receiver showsapplied, never what was asked; a delivered start with noappliedreached a machine that ignored the options. - New event fields (PROTOCOL.md; all optional, tier 0):
session.attended,turn.ended.background,turn.started.submitted_byandinjected,session.launched_by, andsession.account_idapi-<16 hex>for a Claude Code home on another API, named in the pair and helloaccounts[]byname(the host) with noemail. 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
harnessesmay name any agent from the ACP registry that is installed (gemini-cli,opencode,goose,cursor,copilot-cli,droid,qwen-codeand more); their turns are acked in delivery modeapi, and an ack'ssessionmay 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}(plusstrin a grant) where a passkey's{cred, ad, cd, sig}would stand. A 0.10.0 machine sayskey_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'skeyand in akey.add'skey(never trimmed toid,alg,spki); passes a{cred, sig}grant, confirm or pairing proof through as it came; and sayscontrol.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 akey.addthat a key the machine already trusts signed. A receiver that doesn't know device keys keeps working: with nokey_typesit 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
permissionormodelon a start only when the last hello'sstart[harness]lists that level inpermissions, or hasany_modelormodels. A 0.9.0 machine ignores both in silence and runs at its own mode. - Send
fileson apromptonly to a machine whose hello saysprompt_files: true; a 0.9.0 machine refuses the promptbad_fields. - Send a
~folder as a start'scwdonly to a machine whose hello carriesstart; give any other an absolute one (fromfolders). - Never sign
inheritas a command'spermission: it only ever comes from the machine. - Read an absent
attended,background,submitted_by,injectedorlaunched_byas unknown: neverfalse,0or "a person". Keep asubmitted_byyou don't know. - Offer a device key for a machine only when its latest hello lists
deviceinkey_types. A 0.9.0 machine would store it as a passkey and refuse every signature it makes (vector122). - 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/controland its vectors (69is nowfiles-on-permission-answer;90–136are new;verifyAssertionnow 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 atsafe(orread), never wider than the machine's mode, frames the task so the model answers in place, and setsSESSIONPIPE_ASKED_BYandSESSIONPIPE_ASK_CHAINin the child's environment. Apromptor astartMAY carryreply_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.startedanddelegation.ended(tier 0;textat 1,replyat 2), and one session field,delegated_by, read by the hook likelaunched_by. Hello and pairing request gainaskandhandoff. Schemas:control-commandgainsreply_to,control-helloandcontrol-pairgainaskandhandoff,eventgainssession.delegated_by,event-datathe 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}(plusstrin a grant), raw r‖s oversessionpipe.device.v1⏎<purpose>⏎<rp id>⏎<challenge>, purposesgrant,confirmandpair; the challenge is the one a passkey would sign, so the day grant, confirms, pairing andkey.addwork unchanged. A machine verifies by its own record of the key, never the message. No new refusal codes. Machines advertisekey_typesin the pairing request and every hello, receivers incontrol.signing.key_types; a client offers a device key only where both listdevice. Akey.addkey is now checked like an enrollment (an SPKI the machine can import, and every device rule),bad_fieldsotherwise, so an unimportable key is never stored. Key fields are closed: a passkey carriesid,alg,spkionly. Schemas:control-keyis a closedoneOf(passkey | device),control-commandandcontrol-paireduse it, grant, confirm and pairing proof take either signature shape,control-pair,control-helloand the well-knowncontrol.signinggainkey_types. 26 new control vectors (111–136). turn.startedcan 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 fromsession.started.source, which a receiver MUST NOT merge with it); andinjected,truewhen the prompt text is a block the harness wrote rather than words a person typed.injected: truesays 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 asubmitted_byit does not recognise, and MUST NOT let a secondturn.startedfor aturn_idit already accepted reclassify that turn. Claude Code fillsinjectedfrom a closed list of its own wrappers and would fillsubmitted_byfromUserPromptSubmit.source, which no build in the field sends yet; fixtureconformance/harness/claude-code/user-prompt-submit-injected.json. Additive: a receiver that does not know the fields keeps them as extra keys ondata.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:truewhen a person is at the session or drives it from one of the harness's own clients,falsefor a run nobody watches. A receiver MUST read an absent field as unknown, never asfalse. Claude Code fills it fromCLAUDE_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 says0, a Remote Control session included. The hook carriesCLAUDE_CODE_SESSION_ATTENDEDandSESSIONPIPE_CONTROL_JOBinto the job, and the worker reads those andCLAUDE_CODE_HOST_SESSION_IDfrom the job alone, never from its own environment. Additive: a receiver that does not know the field ignores it.- Control: a
startmay carryfiles(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 atGET {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 acksfailedwith codefileand starts nothing.fileson another kind,nullor empty isbad_fields. Schemas:control-commandgainsfiles;control-pairandcontrol-hellogainfiles: true(the machine takes attachments — a receiver should not send files to one that doesn't say so); the well-knowncontrolobject may sayfiles: true. 24 new control vectors (66–89). turn.endedgainsbackground(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'sbackground_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.jsonnow pinsbackground: 1(an Explore subagent still running) andstop-2.jsonbackground: 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 fromSESSIONPIPE_LAUNCHED_BYin the hook's environment. A script that runsclaude -pfor a session sets it, and a receiver can group the run under its launcher without reading it as a subagent, whichparent_idwould say (#65). A subagent keeps itsparent_idand gets nolaunched_by; a launcher that starts a further session resets the variable, so it names the nearest launcher only. - Control:
startis 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). Astartnames 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 — andresumecovers 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 refusedbad_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), andconformance/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 kindstart; new outcomerefusedwith acode; acks may carryreply. - Schemas:
control.jsonis now the carried envelope (cmd,csig,grant,confirm); newcontrol-command,control-grant,control-key,control-pair,control-pair-started,control-paired,control-hello;control-ackgainsmode,session,code,reply; the well-knowncontrolobject gainssigning(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.
- 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
(
- Fixed contradictions: a sink never carries
control(the CLI'ssink add --controlis now ignored with a pointer tocontrol 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 itswebhook-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/.