Commit Graph
94 Commits
Author SHA1 Message Date
Brennan Benson a551aa5fe6 OpenCode structured chat over the Agent Client Protocol (#25845)
* refactor(native-chat): Grok's registration declares where it runs; ACP no longer borrows Codex's location rule

The rule a self-supervised agent child runs under (this machine, no WSL, Windows only with process
start-time proof) is its own module that Codex and the ACP adapter both use. Grok's registration
takes the full account-home resolver signature, and D3's tests build hosts with the agent registry.

* fix(native-chat): Grok follows the ACP runtime's request contract and the managed process's close

A request the agent or a Stop cancels is answered with the agent's own cancelled reply by the code that
owns it (the runtime no longer answers a silent handler), so a Stop needs no separate decline pass. A
permission answer still being saved when the agent stopped waiting is reported unconfirmed, since the
protocol already answered it cancelled. Cancelling the agent's own turn is the plain cancel. Request
rows are matched under their generation-scoped ids. A refusal's reason comes from the dialect's wording
path. The child drops its own stderr tail and close policy for the managed process's, and a close
whose process tree was not proven gone is reported as the adapter contract asks.

* fix(native-chat): a Grok chat Orca already holds resumes without writing what Grok replays

A chat with a saved Grok session reattaches with session/resume where the agent offers it, else
session/load. Either way the call runs inside the translator's load window, so what Grok sends while
it reattaches (its saved exchange, a task the dead process left running, ended by the restart) opens
no turn and writes no row; only context usage reads on. A reply an Orca or Grok crash cut short is no
longer completed from Grok's saved history: it reads like a Claude or Codex chat's, with the existing
notice. The attach window also closes after a failed attach, and a created session that session/resume
reports missing is replaced like one session/load reports missing.

The replay reconciliation is removed: the lane no longer reads the journal, and D3's replayed-input
grammar test and completed-turn check in the assembler go with it.

* refactor(native-chat): a failed Grok reattach needs no window close of its own; its lane is replaced

* test(native-chat): D3's merged tests use the shipped declarations and the launch options main requires

* fix(native-chat): typecheck fallout of the base merges; any agent's empty chat is reusable

Main's idle-empty-chat lookup and launch join now take any registered agent, as the rest of the
launch path does. The refusal check moved into the prompt turns and the prompt-block conversion beside
the turns that send it, keeping both files in their line limit.

* fix(native-chat): a Grok Stop ends the process once Grok settles its turn; the next send resumes

Grok's session/cancel ends only the running turn: work it already moved to the background keeps
running and can begin a turn of its own after the person pressed Stop. Stop is now a session
boundary, as it is for Claude: the cancel answers open requests and lets Grok end the turn, the host
waits a bounded grace for that, then ends the process; the next send relaunches and resumes.
The adapter's own bounded close of a turn Grok began is gone. Its named-turn check stays: the host
ends the session unless the provider declines a Stop naming a turn that has since ended.

* test(native-chat): a Grok Stop ends the process only after Grok answered the cancel

* fix(native-chat): Steer on a Grok card cancels the running prompt, then sends it

A send that reached Grok while a prompt ran was held in the adapter until that turn ended: Steer
on a queued card took the card out of the host's editable queue and meant 'send after this turn'.
It now cancels the running prompt (session/cancel; the session stays) and sends as the next prompt
once Grok answers the cancel, as the common pattern does; a steer behind another cancels it in
turn, so the last one runs. The adapter holds a send only while that cancel lands, so its general
held-send queue and its holdsDispatch report are gone (every send it holds has its turn open in
the journal). An older client's mid-turn send takes the same path. capabilities.steering is
unchanged and still unread.

* refactor(native-chat): a close or Stop cancels a start through the acquire's own abort signal

The host owns the acquire it runs, so it now owns its cancellation: each attach's acquire gets an
AbortSignal, aborted from outside the session's queue by a close and by a Stop admitted now (the
same admission rule as before). The optional abandonStart adapter hook, the router's fan-out to
every adapter and the ACP adapter's session-keyed start map are gone; the ACP adapter keeps an
unkeyed set of starts only so quit can prove their children gone, and keeps a failed start's
unproven child until its exit is proven.
The hook also let a later close ask that child again. The host now does that from state it holds:
a close of a chat with no live child whose record still names an owner process with no death
evidence asks the adapter to release it. The answer is not recorded as proof (the lease probe
does that), so an owner pid an earlier Orca left is never killed or marked gone. Claude and Codex
ignore the signal and hold no such child; their release is a no-op (tested).

* fix(native-chat): a Grok crash that closes stdout before its exit still ends with Grok's last words

On macOS and Linux the agent's stdout ends before its exit is observed, with or without the
supervisor's EOF forwarding, so the connection's loss closed the journal first and its error text
became the session's ended reason, dropping Grok's stderr. The reason is now read at the proven
exit: the agent's last words when it left any, else why the connection closed. The failure already
carried them. Comments that assumed the exit comes first, that early frames past the cap refuse the
start, and that dispatch re-checks image support are corrected.

* fix(native-chat): nothing Grok sends while a held chat reattaches is written, marked as replay or not

The reattach window relied on the dialect's replay verdict, and Grok's frames read as live unless
they carry isReplay, so an unmarked chat frame during session/resume opened a turn that never
ended. D3 now marks every frame inside the window as replay before the translator reads it, so the
translator keeps only context usage whatever the agent marked; options and commands are still
adopted. The translator's load semantics are unchanged.

* test(native-chat): a Stop after a resume finds no turn an unmarked old reply opened

* test(native-chat): a resumed Grok chat keeps its last context reading; the resume refreshes only the window

* test(native-chat): a Grok background task a Stop ended reads as stopped reporting

* refactor(native-chat): quit's stop of each start answers through one promise kind

* fix(native-chat): quit aborts every start the host has in flight before draining attaches

A Grok that never answered its handshake held quit until the start's own 60 s bound, past the
20 s quit deadline. The host's teardown now aborts each in-flight acquire (and any the drain
still begins), so the adapter's own quit controller and its map of starts are gone: a start
has one canceller, the host's signal.

* fix(native-chat): a Grok start's abort stops reaching its child once the start has returned

The listener stayed on the host's signal until the attach finished committing, so a Close in that
window killed the now-live child behind the host's back and it read as Grok crashing. The start
now detaches it when it ends; a later Close goes through the session's own stop.

* fix(native-chat): a close or Stop during any attach phase stops the start before it launches

The attach began its abort controller only after reconciling leases, resolving recovery and
probing the previous owner, so a close or admitted Stop in those phases reached nothing and Grok
launched anyway. The controller now begins first, and the acquisition checks it before asking the
adapter to start.

* test(native-chat): a close during the attach's owner probe asks no adapter to start

Also renames the close test after the hook it no longer exercises.

* test(native-chat): a close's re-ask closes a Claude or Codex child a failed cleanup left

The re-ask is not a no-op for them: when the adapter still holds the child its cleanup could not
prove gone, the close stops it again as a requested close, and Claude persists the handle of the
conversation it ran so the next send resumes it. Corrects the tests' and comment's wording; the
close awaits the re-ask, bounded by each adapter's kill ladder.

* fix(native-chat): Steer during a turn Grok began itself cancels it and sends once it ends

A send while Grok ran a turn of its own (a background task waking it) went straight to Grok, which
queued it behind that turn where Orca could no longer withdraw it, while Stop treated the same turn
as the running reply. The send now waits as a steer, the turn is cancelled once, and the message
goes when the turn ends; a Stop withdraws it and an exit rejects it as never sent.

* test(native-chat): a steer whose cancel Grok never answers ends Grok and is rejected as never sent

Pins the bounded steer cancel kept from the runtime: past the bound the connection closes, the
running reply reads unverifiable, Grok's end reads as its exit, and the waiting steer is rejected
as never sent.

* fix(native-chat): a Grok crash stays a crash when a stop lands before its exit is proven

After the connection broke and the close could not prove Grok's exit, any later stop Orca asked
for (the next start, a Stop, a Close) marked the child as closed by Orca, so the crash read as a
requested close and Grok's last words were dropped; a send meanwhile was recorded unconfirmed.
The connection loss now decides the cause, and a send on that session is rejected as never sent.

* test(native-chat): fixtures this PR's registered Grok and desktop capability made stale

CI's unit shards failed on tests outside the PR's own lists. Each encodes something this PR changes
on purpose: Grok is now a registered agent (the seam test's unregistered agent is now Cursor); the
desktop now advertises registered agents (the restart-offer tests' older client drops that
capability explicitly); the attach context carries the start's abort controllers (the forget-status
double gains them); and the ACP real-host test rig sends to the host directly (listed beside the
other real-host rig in the send ratchet).

* fix(native-chat): a start quit stops is not the queued message's start failure

With quit now aborting a start it would have waited for, the delivery step recorded the aborted
start as the message's failure ("couldn't restart"). After quit has stopped delivery, the step
leaves the message to quit, which settles it as a close does ("The chat closed before this message
was sent."). The test that pinned quit waiting for that start and stopping its child now pins that
nothing is launched behind quit.

* fix(native-chat): a message sent after a Stop or close aborted a start gets its own start

A start the host aborts (an admitted Stop, a close, or quit) returned its refusal to the delivery
loop, which then rejected whatever was queued at that moment with "couldn't restart", including a
message the user sent after the Stop. The attach now reports that the host aborted it, and the loop
re-derives from the journal instead: what the Stop or close withdrew is already settled, a message
accepted since gets a start of its own, and quit's next step stops the loop. This replaces the
quit-only carve-out with the same rule for every abort and every agent.

* test(native-chat): the message sent after an aborted start is answered, so no settlement outlives the test

* fix(native-chat): a Grok model pick Grok never answers no longer holds Stop or Close

The pick runs on the session's queue. It now registers in the host's out-of-queue
abort registry beside a start, so a close, an admitted Stop or quit abandons it, and
the ACP adapter bounds it at 30 s like Claude and Codex. A late answer is still adopted.

* fix(agent-launch): a phone's launch opens a terminal for an agent whose chat it cannot show

agent.launch now reads the caller's capabilities by the rule tabs and restart offers
use (clientRendersStructuredAgent). A phone without registered-agents.v1 gets Grok as
a terminal again, as on main; the host's own callers and desktop clients are unchanged.

* fix(acp): strip every agent hook variable from the ACP child, from the shared list

ACP_CHILD_ENV_TO_DELETE was a second copy of the hook runtime keys that missed
ORCA_AGENT_HOOK_TRANSPORT; it now spreads AGENT_HOOK_RUNTIME_ENV_KEYS beside the pane
identity keys.

* refactor(native-chat): the mutation context carries the provider-wait registry itself

Keeps the host file within its line limit; one field instead of two closures over it.

* fix(agent-launch): agent.launch.v2 still vouches for Claude and Codex chats

The caller rule from the previous commit also turned Claude and Codex into terminals
for a client advertising only agent.launch.v2, whose contract says it opens a chat
(mobile retry-authority tests). Only an agent beyond those two now needs the client to
read it (clientRendersStructuredAgent); the test fixtures go back to what they were.

* refactor(native-chat): drop saved-history adoption from the timeline assembler

The common pattern discards the history a provider replays while loading a
session, so the assembler has no use for an input.history event.

* refactor(acp): drop session/load history adoption from the translator

The common pattern discards the history an agent replays during session/load,
keeping only what it says about the context window. Remove the adoption path
(acp-history-adoption.ts, the adopt option, and the historical background-task
liveness rewrite it fed) so load replay is always dropped except usage.

* refactor(native-chat): a pending input is only Orca's send now

Review follow-up to the adoption removal: drop the comment naming the
provider's saved message, and make requestedAt required since every pending
input comes from input.accepted.

* test(acp): keep the task-result status table on live frames

Review follow-up to the adoption removal: the result-status mapping was only
tested through adopted history, so run the same table on live frames, and
cover an unmarked task notice during a load being dropped.

* test(acp): a frame helper for a shell command Grok is running

* fix(acp): a Grok crash settles through the host's provider-exit batch, scoped to the turn it ended

A Grok crash ended the journal unverifiable before the adapter reported the exit, so the host's
provider-exit settlement found no running turn and wrote nothing: the adapter's failure (with
Grok's last words) never reached the journal, and a later stale-session pass wrote a bare,
thread-scoped cut-short row, so the partial reply was not folded as Claude's and Codex's are.

At a proven exit the ACP lane now ends its running turn interrupted at the exit instant, as the
host's exit contract expects of a child's own translator (Codex's does the same). When Grok's
stdout closed first (every POSIX crash), the turn is unverifiable only until the exit is proven:
the host's provider-exit settlement now takes the exit as proof naming the child's fence and
revises what that child left unverifiable in the same batch, with the turn-scoped row and the
adapter's failure. Claude and Codex write no unverifiable turn of a live child except a command
whose hand-off is in doubt; that turn is now revised at the exit instead of at the next open.

* test(acp): a crash seen first leaves the host no Grok turn to revise

* refactor(native-chat): what a gone generation left unfinished gets its own module

The settlement file passed 300 lines with the exit-proof revision. The unfinished-work reads
(capture, interrupted-by-the-exit, in-progress) are their own concept and move out unchanged,
apart from the exit proof they now take.

* refactor(native-chat): a watched exit revises what its child left unverifiable without reading Stop marks

An exit's own instant is the turn's end, so the revision needs only each row's fence: the
settlement's journal type gains itemFence alone, and the host test fakes say so.

* test(native-chat): drop the duplicate itemFence on the fake that already had one

* test(claude, codex): an exit whose stdout ended first still reports as it always did

The provider supervisor now ends Orca's stdout when the agent's ends, so on every crash EOF
arrives before the exit is seen. Claude's and Codex's connections report nothing at EOF and
report the exit, with its usual reason, once it is seen.

* fix(acp): reopen a chat with session/load, as the common pattern does

An agent that offers both now reloads its session instead of resuming it; the
reattach window still discards what it replays except context usage.

* fix(acp): drop the 60 s handshake bound; an abort fails the start's waits at once

Neither common design bounds an ACP handshake: Close, Stop and quit end a start
that never answers. The abort now also closes the connection, as a kill there
does, so the start settles even before the child's exit is proven. The
host-stopped start refusal only this bound produced goes with it; the idle
sweep keeps its words.

* fix(acp): a Stop naming an ended turn follows Claude's rule

It still stops nothing while another turn is live, but in the gap before a
follow-up's turn opens, which no client can name, it now stops what is in
flight and the session ends, as a Claude Stop does.

* fix(native-chat): a close no longer re-asks a failed start's unproven child

Neither common design retries that stop at Close, and Orca's Claude contract
re-asks only at the next start and at quit. The ACP adapter keeps the child
until its exit is proven and asks it again there, as Claude does.

* fix(acp): a message sent during a turn the agent began itself goes at once

Both common designs send it straight to the agent with no cancel; only Orca's
own running prompt is steered (cancelled, then re-prompted).

* test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer

Uses an audience production sends (one that cannot show every agent), per review.

* fix(acp): launch Grok as `grok agent stdio`, without the update and leader flags

The common pattern passes neither --no-auto-update, --no-leader nor
GROK_DISABLE_AUTOUPDATER; full access still adds --always-approve.

* fix(acp): an agent that ends its stdout, or answers unreadably, is not a lost connection

As in the common pattern, only a broken stdin (or Orca's own close) ends the
agent; one that closed its output but can still be written to stays until a
Stop, a close or its exit. The provider supervisor goes back to its base
content, so Claude and Codex no longer get the forwarded stdout end either.

* fix(native-chat): a person's close joining a failed one still binds the turn its child end cuts

On main every close of the chat writes its own Stop and settle. Here a later close joins the
first and writes no row, and the first's settle closed when its kill failed, so a turn that opened
in between and was cut by the next close read as failed. A person's close joining a person's close
whose Stop opened a settle now reopens that settle until its attempt is done.

Tests: a close whose kill failed still closes its settle; a turn opened between a failed close and
the next reads as the person's cancellation (each fails without its half of the fix).

* test(native-chat): Grok opens as a chat only behind the structured-chat setting

agent.launch and orchestration worker-start read the same setting as the
renderer route; pin both states for Grok on each. The setting's description no
longer names only Codex and Claude, in every catalog.

* docs(acp): generic ACP comments say what holds for every agent, not Grok

Stop ends the session for every ACP agent, as in the common pattern; the
adoption hook comment goes (adoption is not planned); a failed start's child is
retried at the next start or quit.

* test(claude, codex): type the EOF-before-exit test's streams; the supervisor no longer forwards EOF

The Claude test wrote to the child's stdout and stderr through their Readable
type, which the node typecheck rejects; it now holds its own PassThrough
streams. The comments no longer credit the reverted supervisor change.

* feat(acp): a steer's cancel asks once and never ends the agent

The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle,
then close the connection, which ends the agent. A steer used it too, so a slow agent lost its
process just because the person added a message. requestSteerCancel() now sends session/cancel
once per prompt, cancels the agent's open requests and answers later permissions cancelled, and
never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel()
stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel
paths move into acp-prompt-cancel.ts over one cancel channel.

* chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge

* fix(acp): a repeated steer shares the cancel in flight; say what the caller owns

Per review: a second steer before the first write lands returns that write instead of resolving
early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that
fails instead must not take the steer until the caller rebuilds the session; the Stop's says a
prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives
the runtime a handler that would allow: the open permission's signal aborts and the late one never
reaches it.

* fix(native-chat): drop the stopDelivery the A3 merge doubled

* fix(acp): a steer's cancel asks Grok once and never ends it

A steer now uses D1's notify-only cancel. Two messages sent during a reply Grok began itself
cut that reply, as the common pattern does, and then both run; before, the queued first
message could not answer the bounded cancel and Orca ended Grok although Grok answered.
A Stop keeps the bounded cancel and its 4 s grace.

* fix(acp): a permission Grok asks with no prompt of Orca's running is declined

During a turn Grok began itself nobody asked it to act, so the request is answered
cancelled at once instead of opening a card that waits, as the common pattern does.

* fix(acp): a Grok that dies while starting is reported with its own last words

A dying process's stdout ends before its exit is seen, so the start failed as a closed
connection and Grok's stderr was lost. A start whose connection closed now waits, bounded by
the Stop grace (or a Close/Stop), for the exit before it is told.

* test: a Stop after a steer sends its own cancel; drop the import the A3 merge doubled

* test(native-chat): main's Stop-note test builds its turn context with the agent registry

* test(claude): say why the close test's fake child cast is safe

* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.

* test(native-chat): build the Stop-opened-turn test's identity with the opaque handle

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 and this branch both added the import at different lines; the merge kept both.

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 made the same fix as this branch at a different line; the merge kept both imports.

* feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore

A chat whose saved conversation the agent cannot reopen can now continue in a
fresh one: the handle chain records the new conversation as a creation that
replaces the lost one (which, why, and when), keeping every earlier link.

Rows keep a shape older builds read: the stored chain starts at the latest
replacement and carries the earlier links inside it.

* test(native-chat): build this stack's journal identities with main's opaque provider handle

Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test
files from this stack still wrote the old shape. Same lines the downstream ACP branch uses.

* docs(acp): every reattach drops the agent's replay, not only for a chat the journal holds

* refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row

Older builds only read Claude and Codex records, so the nested stored form
protected rows no replacement can reach while adding a cap mismatch after a
downgrade. Store the chain as held, refuse a replacement in a Claude or Codex
chain until one has a stored shape older builds read, and refuse a supersession
key on a replacement that names no creation in the chain.

* refactor(native-chat): read hosts' structured agents from the app-shell services

Main grew the startup hydration hook to its line limit; the host agents sync is an app-lifetime subscription like the structured session tabs sync beside it, so it moves there.

* Use current provider handles in transition tests

* Use current provider handles in timeline fixtures

* test(native-chat): prove replacement rows survive downgrade and re-upgrade

* Require the ACP directory in the runtime import check

* test(ratchet): require src/main/acp now that this PR lands it

* feat(acp): a saved session the agent cannot reopen continues in a new one, with one warning row

When session/load (or session/resume) of a saved ACP session fails, the chat starts a new session and records it as a creation that replaces the lost one (#25747's 'replaces' link), and writes one warning row that the agent no longer remembers the earlier messages. A created session the agent reports missing is still superseded silently; a signed-out agent or a start that is over (Close, Stop, a lost agent) still fails the start.

* chore(acp): rewrap the acquire header comment

* test(acp): a start closed while the agent reopens fails without opening or announcing a new session

* Let ACP connections own their supervised agent process

* Preserve ACP cleanup evidence and isolate exit observers

* Expose ACP cleanup observations and type the permission fixture

* refactor(native-chat): the registered-agents capability lives in its own module

Main's growth put protocol-version.ts one counted line over its 300-line limit once the capability
was added; like main's other per-feature capabilities, it now has its own module, and importers read
it from there.

* refactor(acp): one connection owns the Grok process and its protocol

D3 now opens each ACP agent through createAcpAgentConnection (ACP-ALIGN #25810): one object spawns the
process on the execution host, owns its stdio and protocol, and reports its proven exit. It is built and
tracked before the handshake, so a start's abort (Close, Stop, quit) still reaches it, and a failed start
keeps that same connection for the next close to retry rather than spawning another process.

Deleted: the spawnAcpStructuredChild wrapper and its test, the raw-stream runtime assembly, the caller's
exit -> runtime.close wiring, the stdout-EOF heuristic (the connection no longer treats stdout EOF as
exit), and the 10 s steer/Stop cancel bound with requestSteerCancel. Reader control maps to
pauseReading/resumeReading; a close is connection.close after the host's existing 4 s Stop grace.

The adapter owns what the protocol no longer does: one session/cancel per running prompt however many
steers arrive (cleared with that send's settlement, retried after a failed write), and a Stop or steer
answers every open agent request the person has not already answered with the agent's own cancelled
reply. An answer already being saved when the Stop lands is sent.

Tests: blocked cancel write never holds Stop's grace, two quick steers send one cancel, a failed cancel
write is retried, a real process exiting while a child holds its stdout ends the session, and the
existing start-abort, retention, crash, connection-loss and reload-failure suites on the new rig.

* Add chat-owned OpenCode server transport foundation

* Combine server fixture type imports for CI lint

* fix(acp): Grok signs in on its own machine with its API key or cached sign-in

When Grok reports that it needs authentication, Orca now names a sign-in method on the machine Grok runs
on, read from the same environment Grok was launched with: xai.api_key when XAI_API_KEY is set there and
Grok offers that method, else cached_token when Grok offers it, else none and the chat keeps the existing
not-signed-in refusal. The rule lives in Grok's launch spec; the adapter applies any agent's rule for new
and reopened sessions through the protocol client's caller-named method (authenticate, then retry once).
No new sign-in UI; interactive methods are never chosen.

* Bound request serialization and correct transport fixture types

* fix(acp): the adapter decides which of Grok's requests reach the person

The turn owner now admits every agent request, permission or question, from its own turn state: a
request reaches the person only while Orca's prompt runs and no steer or Stop is cutting it short (a
question may also come from a turn Grok began itself, until a Stop). Anything else gets the agent's
own cancelled reply and opens no card, so a question arriving after Stop or during a steer never
appears. A steer, like a Stop, withdraws the requests already open; an answer already being saved is
still sent. The protocol client's abort-on-cancel path is no longer used: after the connection
change its request signal aborts only when the connection closes.

* fix(acp): a plan Grok proposes shows as a plan, with no approval card

When Grok leaves plan mode it asks the client to approve its plan (x.ai/exit_plan_mode). Orca showed a
blocking 'Approve plan / Request changes' card for it; the common pattern has no such gate. Now the
plan goes into the chat's existing Plan row (the plan-document status row Codex and ACP plan updates
already use) and the request is answered at once with 'abandoned' plus feedback telling Grok to stop and
wait for the person's feedback or a request to implement it in a later turn, so nothing is approved on
the person's behalf. Dialects gain settleRequest for requests answered without asking anyone.

* fix(orchestration): worker-start opens a Grok worker in a terminal, as before

With the structured chat setting on, worker-start decided 'structured' for Grok and then the structured
worker factory (Claude and Codex only) refused it, so the start failed; main opened a terminal Grok
worker. Worker-start now decides with no registered agents beyond Claude and Codex, so Grok gets a
terminal worker as before. agent.launch and the app's own launches still open Grok as a structured
chat. Temporary until structured workers take registered agents.

* Bound OpenCode event consumers and isolate chat caller identity

* fix(acp): a prompt answer Orca can't read ends the turn instead of hanging it

A session/prompt rejection that was not the agent's own error answer (an answer that fails Orca's
schema, or one too large to read) left the turn running: the next message became a steer with nothing
to cancel and was never sent or settled, and Stop waited its full grace. As in the common pattern, any
prompt failure now ends the turn as failed (a failed-turn row without words, since none are the
agent's) and settles the send, so the next message goes. Only a closed connection keeps the send
running, for the connection-loss path to settle.

* fix(acp): send Grok's prompt-identity extension only to agents that echo it

session/prompt carried _meta {promptId, requestId} for every ACP agent, though only Grok's dialect
echoes it (injectedPromptIdentity). Now only an agent whose dialect declares it gets the extension;
other ACP agents get a plain prompt.

* refactor(native-chat): the registered-agents capability lives in protocol-version again, as on main

This reverts 0ef6d21815. That commit moved the capability to its own module only because main's
protocol-version.ts was then one counted line over its limit; main now defines it there itself within
the limit, and main's new restart test imports it from there. Main's test also reads the desktop's
capability list as an older client; on this branch the desktop advertises registered agents, so its
older client is that list without this one capability.

* test(acp): read the sign-in method with a schema, not a type assertion

* refactor(native-chat): composer transport and Stop control in their own modules

Main's rewind change (#19338) brought NativeChatStructuredSession.tsx and use-structured-agent-session.ts
to their line limits, leaving no room for this branch's image-acceptance and unpublished-Stop lines.
The composer's transport (sends, commands, options, image acceptance) moves to
use-native-chat-structured-composer-transport.ts, and whether Stop shows and what it does moves to
structured-agent-session-stop-control.ts. Behavior is unchanged; the runtime cast on the composer's
'local' | 'remote' is now a typed return.

* test(native-chat): read registered agents by id, as main's structuredAgentsReadBy now takes

Main's A3 squash changed structuredAgentsReadBy to take agent ids; this branch's test still passed
{ agent } objects (CI typecheck TS2322).

* fix(acp): a first reopen warns when Grok forgets a chat that exchanged turns

A Grok session the chat created was treated as one Grok never saved, so
when Grok reported it missing on the chat's first reopen, Orca swapped in
a fresh session silently, even after completed exchanges: the person saw
the old messages while Grok had forgotten them.

The launch now counts a created session as never saved only when the
chat's journal, read at the failed reopen, holds no turn of that Grok
session; anything else, an unreadable journal included, takes the normal
path: the fresh session is recorded as replacing the old one and the one
warning row is written.

* Add setting-gated OpenCode server native chat integration

* Use checked native event ordinals in timeline identities

* Validate nested account tags and preserve bounded restore failures

* Keep native history restoration within its session module

* Check native protocol records and support registered-agent drafts

* fix(acp): a start writes the warning row an earlier attach failure dropped

The row saying Grok forgot the chat was written only into the attach's
deferred sink, while the fresh session's link was saved earlier. An attach
failure, quit or crash in between dropped the row forever.

Every start now derives the owed rows: each conversation the chain says was
lost to a failed restore gets its row unless the chat's journal already holds
it. The row now names the lost conversation rather than the fresh session, so
a row an unused replacement wrote still counts after Grok supersedes it.

* fix(acp): a question during a turn the agent began itself gets its cancelled reply

A question or other card-opening request the agent sends while no prompt of
Orca's runs (a turn it began itself, as when a background task wakes it) now
gets the agent's own cancelled reply and opens no card, the same rule
permissions already follow there. Nobody is waiting on that turn. A plan the
agent shares in it is still shown.

* Update launch and status tests for registered structured agents

* test(native-chat): import the unfinished-work capture from the module that owns it

Main's reasoning sweep test (#19221) imported it from the dead-generation settlement, which
this branch split it out of.

* Run OpenCode structured chat over ACP

OpenCode joins the ACP lane as two launch rows (opencode and opencode2 run
'acp'), with a dialect that reads command output and exit codes from
OpenCode's metadata and labels its project-wide 'always' grant honestly.
The launch forces OPENCODE_CLIENT=acp and turns OpenCode's question tool
off, strips Orca's terminal status overlay from the child, and pins the
chat's OpenCode account as a tagged locator (managed profile, or data and
state directories). Image prompts go as ACP image blocks when the agent
advertises them, and restart recovery reads OpenCode's own store for
presence only.

* OpenCode 2 over ACP only for its default account

OpenCode 2's ACP command runs inside the user's own background service,
which the chat's environment never reaches, so a managed profile cannot be
honoured: while one is selected a new OpenCode 2 chat opens in the terminal,
and a record pinned to one never launches.

* Test OpenCode over ACP against its recorded traffic

Seven scrubbed recordings of opencode acp (1.18.31 and 2.0.14) replay through
the adapter: an approved shell command with its exit code and the
project-wide always label, deny on both majors, the 1.x subagent approval
that never arrives (Stop still ends the turn), the 2.x subagent approval
answered on the chat's session, the stray 1.x file write, and a provider
error shown in OpenCode's own words. The replay now plays recorded error
answers too.

* Name the normalized tool update for what it is

* OpenCode over ACP: 1.x only, honest grants, sound recovery

Review round 1:
- OpenCode 2 stays on its terminal-backed chat: its acp command runs in the
  user's own background service, which a chat's environment and account pin
  don't reach, and a declined local launch has no terminal fallback yet.
- The model-pick launch change is out of this PR (it could leave a worktree
  behind when the host later chose the terminal).
- 'Always allow' reads 'Allow for this chat': OpenCode 1.x keeps it in the
  chat's own process.
- A file write OpenCode repeats to a client with no file system leaves no
  row.
- Images are bounded by the encoded prompt line, not just raw bytes.
- Restart recovery: each accepted send claims its own stored copy first, and
  queued cards never trigger the read.
- A send whose child ended during the image read settles as not sent.
- An account Orca cannot pin (inline auth, relative directories) is refused
  as unsupported; default XDG directories the user never set stay unset; the
  retired shared plugin directory and data-account variables never reach the
  child; newer locator fields are tolerated.

* Round 2: recovery reads the chat's own database, honest grant label

- Restart recovery resolves OpenCode's database under the chat's own home,
  not Orca's, now that default data directories stay unset.
- 'Always allow' reads 'Allow until OpenCode restarts': Stop, quitting and an
  idle chat's stopped agent all end it.
- Only a message with images is refused for passing one protocol line, so a
  long text message never reads as an image problem.

* refactor(native-chat): keep the session host within its line budget after main's Stop work

Main's #25949 left the host at exactly its 300-line budget, and this branch's acquire-abort wiring
adds one line. The reveal module now comes in as a namespace import, as the host already does for
its other helper modules, and the earlier reorder of two type imports is undone.

* test(codex): move the stdout-before-exit test into its own file

Main's connection test file is at its 800-line test budget, and this branch's exit-order test
pushed it over (CI lint, max-lines).

* Keep the record's path bound, which the launch directory now uses

* Narrow the refusal in the prompt content test instead of casting it

* Type the replayed error answer as the protocol's error

* Read the replayed message through the replay's own schema

* Match recovered OpenCode messages through main's send fingerprint

Main gave every stored send one fingerprint helper (it drops the sender field); restart recovery now hashes stored messages through it instead of rebuilding the hash by hand.

* Keep an `opencode` that is 2.x on the terminal chat

Homebrew's `opencode` is 2.x now, so the binary, not only the agent name, decides whether OpenCode's acp runs in-process. An ACP launch spec can name the --version releases it runs on; OpenCode takes stable 1.x from 1.18.31. createSupport asks the binary a launch would spawn, with the environment a launch starts from, so a desktop launch of 2.x opens the terminal chat; every launch asks again before spawning, so a binary swapped after create never starts. The --version probe is shared and bounded, and a version it cannot read counts as unsupported.

* Type the mocked login-shell environment instead of asserting it

* Run the OpenCode the Command setting names, and say why a version check refused

The structured OpenCode and Grok chats resolved a bare `opencode`/`grok` from PATH while detection and the terminal chat honoured the per-agent Command setting, so a user who pointed Orca at a specific binary got another one (and, with the version gate, another version) in the structured chat. resolveStructuredAgentCommand now takes any agent id and the stock binary name; the ACP launch and createSupport's version check both resolve through it, so they ask about and spawn the same file. An unrunnable Command is admitted at create and refused at launch with agentCommandNotRunnable, as Claude and Codex do, instead of silently opening a terminal.

The --version probe now logs why it refused (exit code, timeout, oversized or versionless output, unsupported release), with the binary's own first words, so a refusal on a real host can be diagnosed. A real-probe test drives createSupport and the launch against stand-in binaries: per-agent PATH, Command setting, 2.x first on PATH, and an unrunnable Command.

* Say why a new OpenCode tab opened a terminal, and ask the host again for its agents

Live QA run 5 opened the terminal-backed OpenCode chat with the private 1.18.31 set as the Command and on the per-agent PATH, and Orca's main log had no version-check line. The host never decided: a local launch routes to the structured chat only once the renderer has learned the host's registered agents, and that list was read once at startup, so a read that failed (or was skipped while the local runtime's capabilities were unknown) kept OpenCode on the terminal for the whole session. A launch that finds the list unlearned now asks the host again, so the next launch opens the chat.

Every point that sends a launch to a terminal now says so, naming the check and never a value: the renderer's route decision ([agent-launch-route], in the renderer console), createSupport's refusing check ([structured-create-support]), a version check that passed as well as one that refused ([agent-cli-version]), an OpenCode account that cannot be pinned ([opencode-account]), and a host launch the default asked to be a chat ([agent-launch]). createSupport's composition moves out of the unchecked runtime class into structured-agent-launch-support.ts.

Tests reproduce run 5: the host's create check with HOME and every XDG directory in the rig, the Command and per-agent PATH naming a stand-in 1.18.31, and the account pinned to the rig's XDG directories; and the renderer route with those settings, before and after the host's list is learned (ablation: without the re-ask the route stays on the terminal chat).

* Type the mocked login-shell environment in the OpenCode probe tests instead of asserting it

* Pass the OpenCode Command and environment through the route test's store helper, which types its settings

* Open OpenCode's chat on the first new tab, and learn the host's agents when they become available

The renderer routes an OpenCode tab to the structured chat only once it has learned the host's registered agents, and that list was read once at startup with nothing to read it again, so a read that came too early or failed sent every OpenCode tab to the terminal chat.

- agentSession.agents answers from the registrations the host is built from, as createSupport already does, instead of installing the host first: the answer is fixed for this build, so it no longer waits on, or fails with, opening the chat journal.
- The local list is read again when the local runtime's capabilities land, so a startup read made while they were unknown is redone on the event that makes it possible.
- A new tab whose chat route waits only on that list asks the host for it and opens nothing until it answers or 3 seconds pass (the local admission bound), then decides: the first launch opens the chat. A host that does not publish its agents is never waited on.

* Type the new-tab route request as a launch request, which carries the delivery callback

* Declare that agentSession.agents answers from the registrations in the cross-version manifest

Older builds read the list from the installed host, newer ones from the registrations it is built from; the manifest now asks only that Codex is listed, which holds for both.

* Wait for this computer's runtime too before routing a new tab during a slow startup

Field QA: soon after a slow startup, a new tab opened the terminal chat because the local runtime had not reported its capabilities yet, which routes straight to the terminal; the bounded wait covered only an unlearned agent list. A new tab whose chat route waits only on either answer now asks for it and opens nothing until it comes or 3 seconds pass, then decides; the [agent-launch-route] line still names the blocker if it falls back. Tests that never meant to exercise startup now start with the runtime's answer known.

* Wait for the probe that lands the runtime capabilities, not one that failed at startup

When the startup probe fails (a rig whose terminal daemon failed to start), asking again answers null at once, so the new-tab wait ended early and the tab opened the terminal chat. The wait now subscribes to the event that delivers the capabilities, probes once, and stops at the 3 s cap; it applies to both routes, so OpenCode (whose blocker is the unlearned agent list) and Claude (unknown capabilities) both wait for it. Tests run the real capability module with a failed startup probe: Claude and OpenCode open their chat when a later probe lands within the wait, and the terminal chat after the cap otherwise.

* Answer an OpenCode account Orca cannot pin at create support, not as a failed chat

Live QA with inline OpenCode credentials (OPENCODE_AUTH_CONTENT) passed the version check at create support and was refused only at create, so the window showed a failed "OpenCode Chat" with Retry where a declined launch opens the terminal chat. Create support now resolves the account a new chat would pin through the same read-only resolver the model catalog and create use, and answers no, logging the account-pin check, when the host refuses to pin it; any other failure is left for create to state. Create's own refusal stays as the backstop.

* fix(opencode): start the chat with the user's OpenCode environment as-is

An OpenCode chat with no managed profile no longer records data/state
folders or a database choice, no longer refuses inline credentials or
relative paths, and launches with the user's environment unchanged.
Drops the create-support account-pin probe that only those refusals used.

* test(cross-version): build each method's params in their own file, under the line limit
2026-10-07 16:26:27 -07:00
Neil 7abd74e818 Keep the structured-session compatibility manifest within the file limit (#26342)
* test: keep structured session method manifest within file limit

* test: retain the local session thread binding after the manifest split
2026-10-07 16:05:35 -07:00
Brennan Benson 995ef11ce7 feat(native-chat): say in the chat why Orca stopped a reply, and offer Continue (#25675)
* feat(native-chat): say in the chat why Orca stopped a reply, and offer Continue

When the Orca that runs a structured chat (this computer or a paired server) quits, updates or
crashes mid-reply, the chat's stopped row now names the cause and the machine, and a Continue
button sends the existing restart continuation for that cut turn, with or without a restart offer.

- Host: a quit/update writes one turn-scoped row for the turn its stop cut, in today's words, with
  an optional `orcaStop` cause on the providerExited fact; restart adjudication stamps how the
  previous runtime ended on the deaths it proves (crash, or the quit/update it began), so the
  crash row names it too. Older clients keep their single row.
- Host: agentSession.continueInterrupted, capability-gated, rechecks under the session lock that
  the chat still sits on that cut, so a second click or a retry sends nothing.
- Client: the row's copy names the cause and machine; Continue sits above the composer.

* test(native-chat): Continue is not held by a recovery file that never answers

* fix(native-chat): bind an Orca stop's cause to the runtime that held the agent; neutral row, Continue explains itself

- The cause now rides on the host's row itself (`orcaStop` on the status row, beside today's
  words), the same row family and id scheme as the reopen's death row.
- Each recorded owner is stamped with the Orca runtime that holds it; a death proven later (at
  restart, or when recovery stops a survivor) names how that runtime ended: the quit or update it
  began, else a crash. Owners an older build recorded, a terminal's claim, an agent that died while
  its Orca ran, and unreadable quit records all keep the generic words.
- The quitting runtime's word is written first in teardown, before the recovery wait, through a
  bounded asynchronous writer apart from the chat database.
- Row copy: one neutral sentence naming the machine and cause; it drops "You can continue in this
  conversation." while Continue is offered, and Continue's tooltip says what it does.

* test(native-chat): type the Orca-stop test fixtures so the typecheck passes

The cut turn's outcome takes the journal's outcome type, and the stand-in close reads the
provider sink through a checked lookup instead of an index that may be absent.

* fix(native-chat): call a cut a crash only when Orca's runtime started and never ended

A chat said "Orca stopped unexpectedly" whenever its runtime left no quit record, and only the
desktop quit wrote one, so a headless server's restart or update, the Settings relaunch, and a
Windows logoff all read as crashes.

Each runtime now records its own start when its chat store opens, and every graceful exit records
its end through one synchronous entry point: the desktop quit's teardown, the headless server's
stop, the in-app relaunch, the GPU-fallback restarts, the update-install watchdog, and Windows
session end. A crash is a runtime that started and never ended; a runtime with no readable record
(never written, pruned, unreadable) names no cause, so the chat keeps its generic words. One file
per runtime, written durably and only by that runtime, so a damaged file never blocks a later
write and two processes never lose each other's record.

* fix(native-chat): Continue answers once Orca accepts it, not once the agent has started

On a paired server, Continue waited for the agent to start before answering, and the client
gives a paired call 15 s. A slow start (account switch, login shell, a long resume) showed
"Couldn't continue this chat" while the agent was in fact continuing.

Continue now answers when Orca has accepted the message, as a send does. The agent's start and
answer settle afterwards, and a start that fails is the chat's own note, as before. The restart
dialog's batch still waits for the handover, which is where it counts a start as done. The
verdict helpers move to their own module to keep the continuation file within its size limit.

* fix(native-chat): a reply the user steered, or a command run after the cut, still offers Continue

The cut detector stopped at the first user message after the cut turn, so a steer the turn had
taken, or a conversation command such as /context run after the cut, removed Continue while the
row still named the cause.

The rule for what is no request of its own (a conversation command, a row its turn produced, a
send handed into a running turn) moves out of the latest-request reader into one shared
predicate, which both that reader and the cut detector use. The host's "still wanted?" check
reads the same detector, so the client and host agree.

* fix(native-chat): a death proven after an earlier settle explains the turn it ends

When a chat was read before the restart proved its old agent dead, the read could only call the
turn unverifiable. The proof then revised the turn to interrupted, but the death row was scoped
by the turn still marked running, and none was, so it landed on the conversation instead of the
turn. That cut never offered Continue, and the row did not name it as the turn's explanation.

The row is now scoped to the newest root turn the settle actually ends, running or revised.

* fix(native-chat): the cause row's words stay put, and Continue waits out a resume already running

The row's "You can continue in this conversation." came and went with the button: it showed
while a paired host's answer was still on its way, vanished when the button appeared, and came
back the moment Continue was clicked. Continue also appeared on chats the restart prompt or the
launch's own resume was already carrying on.

The row now drops that sentence wherever the chat's host can continue a cut, counting a host
that has not answered yet as able (a host that writes cause rows has Continue), so its words
never change on screen. Continue is hidden while a resume is carrying that chat on.

* fix(native-chat): a refused Continue says so once, in the composer

A Continue the host refused before accepting anything wrote a red note into the chat and brought
the button back, so each retry added another identical note; a chat the host had no record of
was refused with no word at all.

Continue now reports every refusal the same way as a failed request: the existing composer line
"Couldn't continue this chat. Try again, or send a message.", which a retry replaces rather than
repeats. A refusal before acceptance writes no note. A failure after the message was accepted
(the agent could not start) is still the chat's own note, as for any send.

* fix(native-chat): the row naming Orca's stop carries its own presentation and never folds

A client that re-words host rows it cannot name (the draft that makes these cuts read as
interruptions) treated the cause row as an older red row and replaced its words, so the cause
never showed there. The row was also folded away under its collapsed turn once shown muted.

The host's cause row now names the presentation 'orca-stop' beside today's words, failure fact and
red tone, so a client that predates both changes still prints exactly today's row, red and on
screen, and a client that re-words unnamed rows passes it through. This build shows it muted,
counts it as no failure (the reply it cut stays the turn's answer), and never folds it; the fold
field becomes `explainsTurn`, as the other change names it.

* test(native-chat): pass the session-end event without a type assertion

* test(native-chat): the row naming Orca's stop renders neutral, whoever re-presented it

Pins the rendered tone on this build: the stored red row, and the same row after a reader
re-presents it in the neutral tone with its presentation and cause kept, both render muted and
never fold. The phone draws chat rows without tone styling, so it needs no change.

* fix(native-chat): the "Couldn't continue" line goes once the chat is continued

The composer line a failed or refused Continue set stayed on screen while the agent carried on:
after an answer lost in transit, or once another client or the restart prompt continued the
chat. Only the next Continue click or the user's own send cleared it, and a click also wiped an
unrelated composer error.

The line is now derived: shown only while the chat still sits on the cut that Continue failed
on, so it goes as soon as the journal shows the chat continued, from anywhere. A Continue click
clears only its own line, and a retry answered "already continued" leaves none.

* fix(native-chat): Continue waits while an opted-in launch may still resume the chat

With "resume automatically" on, Continue showed on a quit or update cut while the launch was still
waiting for its settings and reading the restart offer, then vanished when the launch's own
resume began; a click in between sent a competing continuation.

The launch's one decision (nothing offered, ask, or resume) is now published, and the chats it
resumes are named the moment it decides, with no gap. Until it decides, and while the setting
has not loaded or is on, Continue stays hidden on this machine's chats; a paired server's chats
are not the launch's to resume and keep it.

* fix(native-chat): a runtime's end survives a late reinstall, a failed write and any clean exit

Three ways the runtime record could still read a graceful stop as a crash:

- A chat host reinstalled during the quit (a request landing after teardown began) recorded the
  runtime's start again and erased the end it had just written. A second start of the same
  runtime now keeps that end.
- When the end could not be written (a full disk), the start alone stayed and read as a crash.
  The runtime now removes its record, so its chats name no cause.
- Each `app.exit(0)` had to remember to record the end. A process 'exit' with code 0 now records
  a quit when nothing else did: Electron emits it on every quit and exit once its loop runs
  (`app.exit` -> Browser::Shutdown -> the app's 'quit' -> process 'exit'), and Node on every
  `process.exit`. The relaunch and GPU-fallback calls it covers are dropped; the quit teardown,
  the headless server's stop, the update watchdog and Windows session end keep theirs, which run
  earlier or say more.

* test(native-chat): build the re-presented row as the plain status item it is

* fix(native-chat): a Continue click clears the composer's old error, so its own failure shows

Since the "Couldn't continue" line became derived, an older composer error (such as "Remove
attachments before using a chat-session command.") outranked it: a failed Continue showed the
old error instead, and a Continue that went through left the old error on screen.

A Continue click is the user's newer action, so it clears the composer's error again, as before;
the line then shows the Continue's own failure, if any. That failure still goes away by itself
once the chat is continued, and nothing but the user's own Continue click clears an unrelated
composer error.

* fix(native-chat): a chat start compares the owner process, not the runtime stamped on it

A chat start checks that the process it just started is the one the record names, by a deep
comparison of the stored owner with the adapter's process. The store stamps that owner with the
Orca runtime holding it, so the check passed only because the store happened to return the record
from before the stamp; returning the published record would have refused every chat start with
agent_session_ownership_unknown.

The start now compares the process identity without the runtime stamp, which says who holds the
process rather than which process it is.

* test(native-chat): count agentSession.continueInterrupted among the structured methods

* refactor(native-chat): derive the structured chat's transcript session in its own hook

Main's appearance work and this branch's Continue wiring together put NativeChatStructuredSession
past the 400-line limit for components. The session the transcript reads moves, unchanged, to
use-structured-chat-live-session.ts.

* refactor(native-chat): keep the Continue capability in its own module

Main grew protocol-version.ts to its line limit; the Continue capability moves to its own module,
as other capability groups have, and the runtime list still names it.

* refactor(native-chat): keep two shared files within their line limit after the main merge

Main left agent-session-record.ts and structured-agent-session-params.ts just under 300 lines,
and this branch's additions put them over. The account-home shape check moves next to the
account-home type it checks (written without a type assertion), and the Continue params move to
their own contract module; the params catalog is regenerated. No behavior change.

* test(native-chat): compare the store directory's files without depending on listing order

The corruption test checks that no file was created or removed by comparing two recursive
listings. Their order is the runtime's: with the per-runtime record directory nested under the
store, Bun returns the same entries in a different order than Node. Both listings are now sorted.

* fix: share the path bound main's launch-directory check needs

* test: give the stop-row fold rows the draws flag main's fold now reads

* refactor: mark the launch's resume decision where the resume begins

* test: count main's new structured method alongside agentSession.continueInterrupted

* fix: the journal database keeps its folder, where runtime end records live

Main's #26038 dropped stateDirectory from JournalHostDatabase; this PR's
runtime end records are read from and written beside it.
2026-10-07 14:48:28 -07:00
Brennan Benson 0f9f199322 fix(native-chat): no saved outbox on the desktop; one send at a time, and the host owns what it accepted (#25959)
* fix(native-chat): the host owns the send queue; the window keeps no saved outbox

The desktop kept each structured chat's unsent messages in localStorage and
sent them in order, so one message whose fate was unknown froze every later
send, Retry dropped it silently, failed sends could not be discarded, and an
offline chat could send hours later. The host already records every message
and owns the queue; the window now only sends.

- One in-memory sender for composer, launch prompts and messages sent from
  outside the chat. One send in flight per chat; a transport failure resends
  the same id for up to 30 s; a refusal that proves nothing was recorded puts
  the text back in the composer with the reason; a send that went out and was
  never answered shows an in-doubt line with Send again and holds nothing up.
- The host's "unknown" rows get the same in-doubt line and Send again, from
  the journal, in every window.
- A message an older build left in localStorage is never sent: the host's
  conversation outline decides what goes back to the composer, and the copy is
  deleted once that is saved.

* fix(native-chat): send through the structured chat RPC wrapper, with its timeouts

The sender called the runtime RPC directly, skipping the per-method timeouts
every other structured chat call gets. Only a remote host's request skips the
compatibility check the sender already ran.

* fix(native-chat): an unconfirmed send goes back to the composer, with no new row line

The common pattern draws nothing extra on a message whose delivery is in
doubt once its turn is over, and puts a failed send's text back in the
composer with the reason. So a send nobody answered in time, or one a host
answers in a way that proves nothing, goes back to the composer worded as
unconfirmed, and a host "unknown" row shows nothing extra. Removes the in-doubt
phase, Send again, and its strings.

A host's made-up row for an id its journal lost now reads as unconfirmed, not
as recorded, so that message comes back instead of vanishing.

* test(native-chat): pin that nothing resends a send given back as unconfirmed

* fix(native-chat): sends survive a tab close, never resend after a Stop, and keep remote images

- A normal tab close lets sends on their way settle; what the host never took
  goes back to the conversation's draft. Only a cancelled launch or a worktree
  purge drops them.
- After any Stop, a send already on its way is never sent again under its id:
  a doubtful answer, or a resend that was due, hands its text back worded as
  unconfirmed.
- A returned image keeps the SSH connection it lives on; the connection never
  goes to the host.
- An older build's saved message the host recorded and then rejected is left
  to the host's own row, never handed back.

* fix(native-chat): a Stop or a tab close never resends a send already out, and a kept card is the card's

A Stop that landed while a same-id resend was being readied (its timer
fired, its request not out yet) let that resend go out after the Stop.
The sender now tracks whether a request is awaiting its answer: a Stop
hands back every send between attempts as unconfirmed, lets one whose
request is out settle from its answer, and never issues a request after
it. A normal tab close withdraws the same way instead of doing nothing,
so nothing more goes out and nothing is dropped.

A send the host rejected but kept as a card (keptAsQueuedMessageId) is
the card's, from its reply or the journal, and never goes back to the
composer.

Adds the freeze tests: a send whose fate is unknown holds later sends
no longer than its deadline, and one the host can neither confirm nor
deny releases the next at once.

* fix(native-chat): read a resend's turned-away call or reused id as unproven

Ports the send-answer proof contract. A call the host turned away before
running it (method_not_found, invalid_argument, unauthorized) proves only
that this request wrote nothing, so it reads as never sent on a first
attempt only; on a resend an earlier attempt may have landed, and it goes
again under the same id. A resent id the host says was already used for
other content (messageIdReused) proves nothing either, like an expired or
conflicting id.

Pins the rest of the contract: any row the host returns is its own, a
thrown error is no answer whatever its code, and an older build's
refused, held or outlived-Stop copy is handed back once and never sent.

* refactor(native-chat): type the structured composer's send with its attachment type

Keeps NativeChatStructuredSession.tsx within the file length limit.

* refactor(native-chat): drop the kept-card guards the sender never needed

A kept send's row is a rejection that was never a Stop's, so the sender
already reads it as recorded, from its reply or the journal. The test
that pins it stays; the two extra checks only covered a kept row that is
also a withdrawal, which the host never writes.

* test(native-chat): route launch tests' sends through the client wrapper the sender calls

The sender sends through callStructuredAgentSession, but these launch
tests replaced that module with a factory that answered nothing (and still
named a probe that no longer exists), so every launch prompt resent until
its 30 s deadline and the tests timed out. Each factory now hands sends to
the runtime RPC mock the tests already answer, and expectations of a local
send no longer ask for the remote-only compatibility option.

* fix(native-chat): hand a message back without importing the composer's attachment hook

The worktree purge reaches the launch prompt, which hands text back, and
the attachment hook's imports reach the store. A test that builds the
real store behind a mocked one then waited on itself and hung. Handing
back now writes images to the draft store directly, as the hook's helper
did, and a test pins that each returned image keeps its SSH connection.

* fix(native-chat): one send per chat, with no line of sends behind it

A chat with a send out took further messages into an in-memory line and
sent them one by one. A message typed behind one in doubt then hit its
own 30 s deadline and came back as not sent without ever going out.

Now, as the common pattern does, a chat takes one send at a time: while
it is out, Send is disabled and Enter leaves the text in the box. The
sender refuses a second send instead of lining it up, so the 30 s
deadline always runs from the send itself. A Stop or a tab close stops
the one send: settled from its answer if its request is out, handed back
as unconfirmed between attempts, or silently if it never went out.

Notes sent from outside the chat while its send is out stay with their
sender (not ready); a launch prompt that meets the person's own first
message waits in the composer instead of being lost.

* fix(native-chat): give a Stop-withdrawn note back to its chat once its notes were cleared

Notes sent from outside a chat clear once their message is recorded. A
message the host recorded as pending and a Stop then withdrew came back
only to its sender, which had already let go of it, so the text was
lost. The chat's draft now takes it, as an earlier build's outbox did.

* test(native-chat): type the composer-actions probe without a cast

* chore(native-chat): drop outbox wording left in comments and an empty locale group

* fix(native-chat): pace failed checks, keep the host's reason, and hold the chat for its launch prompt

- A send whose checks failed before its request went out (an unreachable
  or incompatible host, an unreadable history) was tried again at once,
  about a thousand times a second for 30 s. Attempts are now paced by
  the attempts made, whether or not their request went out.
- A host that refuses every resend by throwing (native chat turned off,
  a journal that won't open) gave back only "couldn't confirm". The
  host's reason now comes first, still without claiming not sent.
- A launch's prompt now holds the chat's one send from the click, drawn
  as sending, so a message typed while the chat starts can't overtake
  it; it goes out once the chat exists, and a cancelled launch frees it.
- Notes whose send nobody could confirm say so instead of "did not
  accept", and notes launched into a new chat let go of their text once
  that chat's composer holds it.
- An open chat keeps drawing a recorded send until its row arrives, so a
  reply that beats the history no longer makes the message flicker out.

* fix(native-chat): hold a chat's sends until it has started, and send a failed chat's message with its restart

A message sent to a chat still starting went out at once to a host that
had no record of the chat yet, read for a fence it could not get, and
came back as not sent. A message sent to a chat whose start failed
restarted it but no longer went with the restart.

While a chat starts, Send stays off and Enter leaves the text in the box,
as with a send already out. A text message sent to a chat whose start
failed restarts it and goes as the restart's first message, through the
same staged-prompt path a launch prompt takes: it holds the chat's one
send slot, drawn as sending, until the chat exists, and comes back to
the composer if the restart fails again. Notes wait while a chat starts
and ride a failed chat's restart, keeping their text until it is sent.

* chore(native-chat): test the queue request, rejection words and card hand-offs; drop an unused clear

- Pin which sends ask the host to queue, the moved rejection wording,
  and that a queue send whose card was handed off and then refused or
  withdrawn, or whose replay names a withdrawn card, is never handed
  back.
- The send-at-most-once gate now says what the desktop promises after a
  reload: it never sends the id again, and hands the text back.
- The legacy read names when it goes, and drops the notice clear nothing
  called.

* fix(native-chat): keep a sent launch prompt's entry, and give back at once what can't go out

- Cleaning up a launch prompt released its send slot even after the
  prompt had gone out, which deleted the entry the sender keeps once the
  host records it. A launch prompt recorded as pending and then
  withdrawn by a Stop was lost from both the chat and the box, and an
  open chat dropped the new chat's first message before its row arrived.
  The slot now gives back only a reservation that was never sent.
- A send stopped before its request went out by something trying again
  won't clear (this client and the server can't talk, or the host
  refused the history read) kept Send off for 30 s and then said Orca
  couldn't reach the agent. It now comes back at once with its own
  cause: not sent, since nothing went out, or unconfirmed if an earlier
  attempt did. Transport errors keep the paced retries.

* fix(native-chat): notes keep their own text through a new agent's launch

Notes sent to a New agent whose start failed came back to the notes and
also sat in the new chat's composer, so sending both delivered the text
twice. The notes keep their text until it goes out (they hold it from
the click), so the launch now says so and no composer gets a copy, on a
failed start or a refused prompt alike. The tests that asserted the copy
in the composer pinned the old double ownership and now assert the
notes are its only owner.

Notes that rode a failed chat's restart and ended unconfirmed now say
Orca couldn't confirm them, as notes sent directly do, instead of that
the agent did not accept them.

* chore(native-chat): the send-once gate says what the desktop keeps across a reload

The desktop keeps a send's id in memory only: a send still unsettled at a
reload or crash is not resent and not handed back. The coverage notes no
longer credit the renderer tests with durable identity across a remount.

* fix(native-chat): say once why notes sent to a new agent did not go

Since notes keep their own text through a new agent's launch, a prompt
the host refused, nobody could confirm, or that found the chat's send
taken came back to the notes with nothing said anywhere: the chat shows
no notice for text its caller keeps. The notes menu now reports it once,
with the toast a send to an existing chat already uses: not accepted,
couldn't confirm, or not ready. A start that failed still says so in the
new chat instead.

* fix(native-chat): retry a history refusal the host says clears, and name it at the deadline

A send stopped before its request went out came back at once for any
refusal of the history read, including ones the host names as clearing
(its journal briefly unavailable, a chat detached while the host quits),
so the automatic retry was lost. Only a version mismatch and refusals
that won't clear come back at once now; the rest go again on the paced
schedule, and if the deadline still finds nothing sent, the words are
the host's refusal rather than Orca couldn't reach the agent.

* feat(native-chat): a send makes one request, and nothing ever sends it again

The desktop resent a message under its own id for up to 30 s when the
answer was lost. A send now makes exactly one request, as the common
pattern's clients do:
- An answer that is lost, dropped or proves nothing hands the text back
  at once with "couldn't confirm… check the chat".
- A failure before the request goes out (the environment check, the
  history read for the fence, a refused connection) means nothing went
  out: the text comes back at once with its own reason, or as not sent.
- The 30 s cap stays on the one request, so a host that never answers
  can't hold Send.

Nothing ever resends, so nothing can go out after a Stop: a Stop only
takes back a send still in its pre-send checks. The resend state goes
with it (tries, generation, awaiting, stopped, the resend timer, the
send-answers-proof probe, and the first-attempt/resend split in the
evidence), and the send-once gate says the desktop never resends.

* fix(native-chat): notes keep the chat's line, and a send held behind a rewind says it was not sent

- Only a send whose text belongs to the chat's composer clears the chat's line. Notes sent from
  outside the chat no longer wipe a "couldn't confirm... Check the chat" line that explains text
  already back in the box.
- A send the host turns away behind a rewind it could not confirm went back as "not sent" but said
  "couldn't confirm what happened. Check the chat". It now gives the rewind's reason and says the
  message was not sent.
- Reliability gate names the single-request test and records a fresh evidence run; the sender
  test drops its leftover resend mocks and a duplicate Stop test.

* fix(native-chat): a send ends even when putting its text back fails

If writing the returned text into the chat's draft threw, the send never settled: it stayed
"sending", the chat refused every later send until a reload. The send now always ends after a
hand-back, the failure is logged, and the chat's line adds "Couldn't save your message."

* fix(native-chat): a message typed during /clear stays in the box and follows the chat

A /clear moves the chat to a new conversation, and its host refuses any send while it runs. A
message sent then went out, came back refused into the old conversation's draft with its line, and
vanished when the chat moved on: the box and the line now belong to the new conversation.

- A /clear holds the chat's one send slot while it runs: Enter does nothing, Send shows busy and
  the text stays in the box, as for a send that is out. Notes sent from outside get "busy".
- Once it moves the chat, the old conversation keeps taking no send until the view leaves it, and
  what is left of its draft moves into the new conversation's draft, after anything there: when the
  composer's /clear settles, and again when the view moves.

* fix(native-chat): no "Send message?" or queue clear while the chat's send is out

While a send is out (or a /clear runs) the chat takes no message, yet Enter over a held queue still
opened "Send message?", and Clear queue deleted every card before its message was refused. Enter now
does nothing there and the text stays; Clear queue re-checks and deletes nothing if a send went out
after the question opened.

* refactor(native-chat): take the /clear draft carry out of this change

Moving the old conversation's draft into the one a /clear replaces it with fixes a bug main has too
(text left in the box during a /clear stays with the old conversation), so it goes in its own change.
Kept here: a /clear holds the chat's send slot while it runs, and the conversation it moved away from
takes no send until the view leaves it.

* fix(native-chat): a /clear's hold on sends always ends

- A view that unmounted while a /clear that moves the chat was out left the old conversation
  holding its sends until a reload: the late reply kept the hold for a view that was gone. The
  reply now releases it.
- The local call for a conversation command has no deadline of its own, so a /clear that never
  answers kept Send off for good. The hold now also ends at the command's deadline (195 s, the one
  the remote call already uses), whichever comes first.

* test(native-chat): opening a chat an older build left stuck; hand back its copy in send order

Pins, through the real chat hook, sends, legacy recovery and draft store,
what opening such a chat does: the queued messages behind a message the
host recorded in doubt come back to the composer once, in order, with the
"not sent" or "couldn't confirm" wording; the chat is free to send under
its read's fence; nothing happens while the chat has no fence here.

The legacy reader now hands entries back in the order they were sent
(queuedAt), as the older build's reader did, instead of array order.

* fix(native-chat): a send this window turned away for a re-paired server comes back as not sent

A managed server's update rotates its pairing, and this window's main
process then answers the next call itself, before forwarding anything,
with runtime_environment_changed. The send read that thrown answer as
proving nothing, so the person was told Orca couldn't confirm a message
that never left. It is now handed back as not sent, with the reason.
Every other thrown answer still reads as unconfirmed once the request
may have gone out.

* test(native-chat): a message refused for an expired attachment comes back with its file and why

A paired server checks every stored file a message names when it admits it,
and refuses the whole message before recording it when one has expired. The
sender hands such a message back to the chat's draft, file included, with the
refusal's words, whether or not a view shows the chat, so it can be removed
and attached again. Ported from the saved-outbox test that came with
attaching files to a structured chat on a paired server.
2026-10-07 14:07:30 -07:00
Brennan Benson 98f0193afb feat(native-chat): show agent-written visuals inline in structured chats (#26103)
* feat(native-chat): visual directive grammar and host read for a chat's visuals folder

A shared grammar for the ::orca-visual{file="..." title="..."} reply line,
the per-chat visuals folder location on the owning host, and the
agentSession.readVisual runtime method that reads one visual with lexical and
canonical containment, a 512 KiB bounded read and UTF-8 refusal.

* feat(native-chat): render chat visuals inline and in the right sidebar

Native-chat assistant replies render a ::orca-visual{...} line as the chat's
HTML visual in an opaque, scripts-only sandboxed frame: CSP first, the host
frame navigation guard registered before content runs, live theme without a
reload, fitted height, links opened in the viewer's browser only from a real
gesture, lazy mount, and one muted line when the visual cannot be shown.
Open in sidebar shows the same frame in the right sidebar, widened while it
is open and restored after.

* fix(native-chat): visual CI fixes, shared height governor, live-turn streaming hold

Registers agentSession.readVisual from the methods index so the structured
method file stays under its line budget, replaces reflective reads with checked
narrowing, moves the pure height governor to src/shared for mobile, and holds a
half-written directive tail while the turn works (structured text rows carry no
running state).

* fix(native-chat): harden the visual read and link opening

Re-checks after the open that the chat's visuals folder is still the real
directory at Orca's path, reports unexpected filesystem faults by code without
host paths, and lets one click in a visual open at most one page.

* fix(native-chat): keep visual lines out of plain-text reply surfaces; review fixes

One shared helper drops visual lines (outside fenced code) from reply text where
it becomes plain text: the structured status summary that feeds the sidebar row,
dashboard, notifications, phone rows and handoffs, and AI Vault reply previews.
Review fixes: height also counts a pinned body's overflow, only the live
frontier row holds a half-written visual line, the runaway-height stop needs the
same step repeated, and any host refusal evicts the cached revision.

* fix(native-chat): resolve the visuals folder without the removed journal-paths helper

Main removed the per-chat journal paths and the journal database's state
directory; the visuals folder keeps the same sha256 layout on its own and the
read method uses the profile state directory the chat host is opened in.

* fix(native-chat): review round 2 fixes; copy a reply without its visual lines

Reply previews in Agent Session History drop visual lines per text part before
lines are folded; the frame adds a body's overflow only when the body really
overflows; fence tracking follows CommonMark closers and openers; the copy
button copies a reply without visual lines; a coded read fault keeps its cause.

* fix(native-chat): update the frame's theme ref after render; read the visuals folder pair at once

* test(native-chat): declare agentSession.readVisual on the cross-version agent-session surface

* fix(native-chat): copying a reply keeps its code blocks and indentation

Removing visual lines now closes only the gap each removal leaves, instead of
collapsing blank lines across the whole reply and trimming its indentation; the
visuals folder is checked parent first again so a broken path answers the same
way every time.
2026-10-07 11:11:35 -07:00
Brennan Benson 80d45095d2 fix(native-chat): a message kept after a quit or close waits in order and follows the chat's next turn (#25960)
* fix(native-chat): drop the queue-paused header and Resume button

A Stop, a restart or /clear holds the queued cards. The hold stays; only the
header row naming why, and its Resume button, go. A held card shows no
caption, and its own Steer, or any new message, releases the queue.

* test(native-chat): type the unknown hold reason a newer host may publish

* fix(native-chat): a held card offers Send, not Steer, when no turn runs

Steer vs Send now follows whether a turn is running, not the card's hold,
so a card held after a Stop, a restart or /clear reads Send.

* fix(native-chat): the queue sends past held cards instead of stalling behind them

A card queued after a Stop (or written after a restart or /clear) sent only
once the cards held before it were released; with no header to explain or
release the hold, it sat silently. The next sendable card now skips held
cards; a returned card still blocks what is behind it.

* fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it

After a Stop, a card queued later sent past the held cards, but the queue
recorded that send as Orca's own turn. It never ended the Stop's pause, so
the held cards then waited forever with nothing on the card saying why.

A queued card is always something the person wrote: only the client send
RPC may now create one. The queue's send of it is therefore recorded as the
person's turn, which ends the Stop's pause once the agent takes it, and the
held cards then drain in order.

* fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn

Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery),
so "every card is a person's" no longer holds by refusing host sends. Each card
records who wrote it (the submission's client/host vocabulary) in a new nullable
column; the drain records that origin, so a person's card ends a Stop's pause
and Orca's does not. /clear carries the author. Rows from before the column
read as a person's. The userSend-only admission gate is removed.

* docs(native-chat): state why an unrecorded card author reads as a person's

* fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume

A restart's pause held every waiting card, including one a person typed after the restart while
Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only
cards another host process wrote, the same way a Stop holds only cards queued before it.

The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the
host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the
existing agentSession.queuedMessagesResume, guarded against a second press in flight.

A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its
Steer no longer flips to Send for the frame in between.

* fix(native-chat): the host publishes which pause holds each queued card

The host published one pause for the whole queue, so a client held every waiting card while it was
set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the
composer could flash Resume and the cards Send, and a card queued after a Stop lost its
"Waiting for your answer" caption.

Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from
the same rule the drain reads. A client holds only those cards; against a host without the field
it falls back to the queue-level pause.

* test(native-chat): Resume needs the queue capability and is disabled whenever Send is

* docs(native-chat): describe per-card holds in the queue contract and table comments

* fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus

After Resume, the host lifts the hold in one update and sends the first card in a later one. In
between nothing was running, so the composer's button flashed a disabled Send. A card nothing
holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled
until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown,
read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip
between queued turns.

Resume disables the button, which dropped keyboard focus; focus now returns to the composer.

* fix(native-chat): the host names the card its queue sends next, so the chat stays working across the gap

A turn's end, or a Resume, and the queue's send of the next card commit as two host updates. In
between nothing was running, so the working status, timer, pickers and composer button flipped
for one update. The client guessed the drain from its own copy of the host's gates, which missed a
/clear-replaced source and covered only the button.

The queue publication now carries `nextQueuedMessageId`: the drain's own next card through the
drain's own gate (`nextStructuredQueuedMessage`, which the drain step now calls), null whenever the
host would refuse the send. The client derives one fact, the queue is about to send, and every
working reader follows it; Stop stays disabled until a turn can be stopped. The client-side copy of
the gates and the status-feed rewind read are removed.

* test(native-chat): the queue's next card survives the coalescer, the reducer and a history page

* test(native-chat): build the snapshot that names the next card through its helper

* feat(native-chat): a held queue keeps its header row, and a new message asks before passing it

The queue's header row ("Queue paused because you interrupted", or Orca
restarted, or you cleared the conversation) comes back above the cards it
holds, with Resume; it names the oldest held card's pause, as the host
publishes it per card, and hides over cards held only on their own or
returned. The header's Resume and the composer's share one in-flight guard.

A held card reads Steer again whether or not a turn runs; a card held on its
own or returned keeps Send.

Sending a message while the header shows (Enter or the button) first asks
"Send message?": Clear queue deletes every card and then sends (a failed
delete sends nothing), Send message sends and keeps the cards, which follow
the new turn, and dismissing sends nothing and keeps the draft. Host
commands send as they are.

* fix(native-chat): the paused row goes while your own message is on its way to lift it

After "Send message" over a held queue, the row kept saying "Queue paused…"
until the agent accepted the new turn. The chat now reads that gap from the
outbox: while this composer's direct send is recorded by the host and not yet
accepted, the controller shows no paused row (and so no Resume or
confirmation). A refusal settles the entry and the row comes back, since the
hold did not lift. Orca's own sends never enter this outbox, and the queue's
send of a card goes under a fresh id, so neither hides it. Nothing is stored.

* fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast

The closing dialog stays mounted and clickable through its exit animation,
and a double-click or a held Enter lands twice before any re-render, so
Send message (or Clear queue) could send the captured message twice. The
pending send now lives in a ref that the first choice takes; a second one
finds nothing.

Clear queue deletes one card at a time and stops at the first failure, so a
failed press shows one toast instead of one per card.

The dialog keeps its compact width at desktop sizes and the primitive's
narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact
confirmations).

* fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits

After Clear queue, the message waited in the composer while the cards were
deleted one by one. A second Enter in that window sent it again, and text
typed meanwhile was wiped when the chained send was accepted.

From the Clear queue choice until its message has gone out, the composer's
structured send does nothing. The chained send (and Send message's) now
carries the composition it was taken from, and the composer is cleared on
acceptance only if it still holds exactly that, as host commands already do.

Also: the v1 contract comment names `nextQueuedMessageId` and its absent-
means-null fallback, and the own-send check returns at once on an empty
outbox.

* fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself

- Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person,
  Orca's own messages, or the queue), and so does Resume. The card and submission
  author fields that only fed the old person-only rule are gone.
- The queue sends strictly in order: a card never overtakes a held one.
- After a restart nothing sends by itself and no paused row shows: the chat's next
  turn (the carry-on, or the person's own message) runs first, then the cards.
- Resume and "Send message?" are offered only while nothing runs and no prompt waits.

* fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too

* fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way

- The queue stops handing cards off when the host tears down. A card sent during
  a quit was refused at close, and that refused send withdrew the chat's restart
  offer, so resuming after the relaunch sent nothing.
- The host publishes no pause while a turn sent after it (your message, Steer, or
  Orca's own) waits for the agent; a refusal shows it again. This replaces the
  client's own-send check.
- A card written after a restart is an ordinary card again: it waits while any
  card from before the restart still waits.

* refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression

* refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue

* fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props

- The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's
  Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as
  usual. The pending choice records the hold it was asked under; nothing new is stored.
- The composer-field Resume test passes main's dropScopeKey/draftScopeKey.
- The dialog test expects main's rule: only the sent text leaves the composer.

* fix(native-chat): a message kept after a quit or close waits like every other card, and follows the chat's next turn

A message Orca accepted but never handed to the agent before a quit, crash or
close came back as a card held on its own ("Not sent yet — press Send"): the
queue skipped past it, so later cards sent first, and only the person's own
Send released it. It is now an ordinary card at the head of the queue that
waits, with every card the chat closed with, for the chat's next accepted turn.

One rule for a chat that was not running, derived from the journal: when this
host first opens a chat (after a restart or crash), or a person closes it, and
cards are waiting, a reopen mark is written (a tombstone carrier key, like the
Stop and Resume marks). The cards queued before it wait until a turn is
accepted or Resume comes after it; nothing sends by itself, and no paused row
shows, a Stop's included. The idle sweep's own eviction writes nothing and
changes nothing the person sees. A mark that cannot be written leaves the open
working and holds from the open itself until the next turn; the next open marks
again. A rewind restates the mark. /clear's carried cards also wait unshown.

This replaces the host-instance comparison and its adoption write, and the
'kept' hold (stored 'kept' and legacy 'stopped' holds now read as none).

* fix(native-chat): mark the reopen in the one open path, and keep an idle chat with waiting cards open

Review round 1: the startup restore opened chats past the per-host first-open
mark, so their cards could send by themselves after a quit or crash; a card
mid-hand-off at the open got no mark; a close that left the chat open re-marked
after every new send.

- Every open marks when a card waits, or is mid-hand-off with its send unanswered.
- The idle sweep keeps a chat's handle while cards wait, so its eviction never
  reopens one and stays invisible; it drops it on the next sweep once they leave.
- A person's close marks once; its delivery re-check marks only when it settled
  a send.
- A failed mark holds from where the mark would have gone.
- A Stop made after a reopen shows its row.
- The rig's restart is a real quit and relaunch.

* fix(native-chat): review round 2: restore the dropped Resume and failed-Stop tests; a late mark starts where the chat stopped

- Restores eight tests the previous commit dropped by mistake.
- A mark the delivery loop or a close's re-check writes after a later send starts
  where the chat stopped, so that send still lifts it; a later mark never narrows
  an earlier, wider one.
- Only the idle sweep's own close keeps a chat with a card waiting (or mid-hand-off)
  open, and it still releases an ended child's lease first; a person's close
  drops it as before.

* test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause

Main's #24660 test published queuePause 'restarted', which this branch's wire
type no longer lists, so the mobile tests typecheck ratchet failed.

* refactor(native-chat): settle a restart's leftovers and mark them in one host-lifetime step

Keeps the delivery loop under its line limit after main's Stopping change; no
behaviour change.

* test(native-chat): a kept card's Resume and failed-mark tests quit through the held start's release

Main's #25152 holds the start these tests send into; quitting without releasing it left the quit waiting.
2026-10-07 10:29:52 -07:00
5cafefe726 Phase 3: every SSH host runs a managed Orca server (orcad), replacing the relay (#24863)
* Revert "revert: take the 26 Phase 3 (#16741 port) PRs back out of main (#24559)"

This reverts commit 5f308bfa9c.

* feat(orcad): Windows remote primitives for managed orcad hosts (W1) (#24525)

* feat(orcad): Windows remote primitives for managed orcad hosts (W1)

* refactor(orcad): run Windows host ops as node.exe with plain argv, no PowerShell hop

* fix(orcad): refuse secret-shaped names on the breakaway launcher's --env

* fix(orcad): name the secret env guard for its role

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(orcad): stage and commit a dormant migration catalog on the managed server (#16741 T6-9) (#24521)

* feat(orcad): stage and commit a dormant migration catalog on the managed server (#16741 T6-9)

The destination half of a catalog migration: an orcad stages a T6-7 manifest
(repositories, project groups, folder workspaces, dormant session, client,
automation and worktree metadata, retired names, scrollback snapshots) with
exclusive claims, then commits it with a receipt so a retried commit returns
the same receipt and never imports twice. Served as orcad.migration.* runtime
RPC behind the orcad.migration-catalog.v1 capability; the client refuses a
host without it or with method-not-found, and any other failure is left for
the caller to recheck. Dormant only: no live PTY projection. Inert on the
desktop until T6-10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(rpc): catalog the orcad.migration params in the shared contract; name the catalog-import install target

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(ssh): journal and fence an SSH host for dormant migration, gated on proven terminal exit (#16741 T8-c1+c2) (#24522)

A migration from a relay-hosted SSH target into a managed orcad now starts with
a journal in its own sidecar directory, then the target's managed-owner fence,
then a profile flush, before any remote call. A fence with no journal is
unverifiable and never released; a journal whose fence is gone is stale and
grants nothing; an unreadable journal fails closed. The fence requires every
terminal the target ever leased to be proven exited, checked before the fence
(with the relay's process list) and again under it. Same-owner claims now need
the durable record that explains them. Inert until T8-c4/T6-10.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(orcad): run orcad itself on Windows hosts (W2) (#24529)

* feat(orcad): run orcad itself on Windows hosts (W2)

* test(orcad): load the ConPTY smoke's addon from out/orcad so the temp slot can be removed

* test(orcad): skip the foreign-uid lock case when running as root

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a connected but unused SSH host previews as movable (#24609)

The untransferred-dependency census counted activeConnectionIdsAtShutdown naming the
target as workspace-session state. The renderer rewrites that list on every connection
change, so merely connecting to an empty host blocked the move. It is a reconnect hint;
the remote work it can stand for is counted on its own. The empty-target claim check
likewise ignores global-field copies inside the host's session partition.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(ssh): stage, commit and abort a dormant migration against its destination (#16741 T8-c3) (#24523)

* feat(ssh): stage, commit and abort a dormant migration against its destination (#16741 T8-c3)

The coordinator re-checks before every stage and commit that the fenced source
still exports the journaled manifest, carries no untransferable state and
started no terminal. A lost answer is re-read from the destination's catalog
state; only a committed read whose receipt matches the journal advances it,
and the journal is on disk before anything returns. Abort releases the fence
only on proof the destination holds nothing, or on an unsupported destination
before anything was staged, and never once the destination committed. Codes
against T6-9's catalog client through an injected interface. Inert until
T8-c4/T6-10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(ssh): import the T6-9 client's unsupported refusal instead of mirroring it

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(orcad): deploy, activate and roll back orcad on Windows SSH hosts (W3) (#24563)

* feat(orcad): deploy, activate and roll back orcad on Windows SSH hosts (W3)

* test(orcad): exhaustive op switch in the Windows lifecycle fake

* test(ssh): narrow the Windows host-cell descriptor by lane before building a relay cell

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11) (#24608)

* feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11)

* fix(serve): keep orcad selection app-side and wait out Windows temp cleanup

* refactor(orcad): move the data-root privacy check out of the instance lock

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(serve): prove D7 and the profile lock across a real Electron/orcad serve switch (#24619)

* test(serve): prove D7 and the profile lock across a real Electron/orcad serve switch

* ci(e2e): install ripgrep for the serve mode-switch job's window-manager wait

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(ssh): convert an SSH host with Orca state into a managed server through a journaled migration (#16741 T8-c4) (#24562)

* feat(ssh): convert an SSH host with Orca state into a managed server through a journaled migration (#16741 T8-c4)

The conversion entry resumes or takes the fence, deploys and pairs the
managed server into it, marks the server as migrated, then stages and commits
the dormant catalog. Every step is keyed by the journal, so a repeat after a
crash, deferral or lost reply resumes the same migration. Status reports an
unfinished migration, and rollback is refused while one runs or when the
rollback snapshot predates the migrated catalog. Inert until T6-10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* style: oxfmt the c4 conversion and maintenance files

* fix(ssh): name the fake migration destination's type so declarations stay portable

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(orcad): decommission, managed stop and GC on Windows SSH hosts (W4) (#24570)

* feat(orcad): decommission, managed stop and GC on Windows SSH hosts (W4)

* fix(orcad): accept a managed stop request whose lock path is spelled with Windows client separators

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(ssh): retire a migrated SSH host's source state only after a proven commit (#16741 T8-c5) (#24565)

* feat(ssh): retire a migrated SSH host's source state only after a proven commit (#16741 T8-c5)

Once the journal records destination-committed, the source profile drops the
manifest's repositories, folder workspaces and unreferenced project groups,
its dormant session, automation, client and worktree state, and the leases
the fence proved exited. The profile flushes, the retirement is verified,
the journal moves to source-retired and compacts once the server matches.
A retry after any crash repeats idempotent work. The fenced target stays: it
carries the managed server's tunnel. Conversion now ends retired. Inert until
T6-10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(orcad): retirement drops the migrated host from the reconnect hint

The census no longer treats activeConnectionIdsAtShutdown as untransferable (#24609), so
retirement must remove the target from it; otherwise a restart dials a host that is now a
managed server.

* style: oxfmt the c5 conversion file

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(orcad): convert Windows relay-hosted SSH targets to managed orcad (W5 part 1) (#24579)

* feat(orcad): convert Windows relay-hosted SSH targets to managed orcad (W5 part 1)

* test(orcad): start the Windows lane's exec spy after the relay gate prelude restores its own

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(settings): managed servers and "Move to managed server", behind an experimental setting (#16741 T6-6 + T6-10 UI) (#24590)

* feat(settings): managed servers and "Move to managed server", behind an experimental setting (#16741 T6-6 + T6-10 UI)

Adds a Managed servers section under Remote servers (deploy an empty server,
status with deferred-update and migration states, update, rollback, recover,
stop and cancel-stop, and SSH access for paired servers), and a Move to managed
server action on connected macOS and Linux SSH hosts with a preflight summary,
a terminals-closed confirmation and a resumable progress view. Main wires the
conversion to the relay's process list, the direct session and the T6-9 catalog
client. Everything is hidden until the new experimental setting is turned on,
and Windows SSH hosts are never offered. Merges the T6-9 branch (#24521) until
it lands on the integration branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(settings): align managed-server form controls and name the section the setting reveals

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(orcad): ask the relay with an absolute deadline via the W5 terminal-gate lister

The conversion wiring passed a relative 10 s as listProcesses' deadlineMs, which the
provider reads as an absolute time, so every relay inventory timed out after 1 ms and
the terminal gate could never prove exit. Adopt #24579's lister verbatim so the stacks
merge cleanly.

* feat(settings): name blocking saved state in plain, localized words

The move preview listed internal dependency ids such as workspace-session; each kind
now has its own catalog entry.

* feat(settings): offer managed servers and the move on Windows SSH hosts

W1-W5 are on the integration branch, so a Windows relay-hosted host can deploy, convert
and retire like a POSIX one. The move still waits for a connected relay that reported
its platform.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(ci): list the serve mode-switch e2e job in the release-cut permissions matrix (#24865)

* test(ci): list the serve mode-switch e2e job in the release-cut permissions matrix

* test(ci): expect the orcad Windows host cells in the SSH Windows hosts workflow

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(orcad): wait for killed terminal daemons to exit before removing their temp profiles (#24871)

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(updater): read rollout kill switches from the update-campaign payload, all inactive (#24867)

The nudge request Orca already polls may now carry an optional versioned rollout block
naming the Node runtime flips. A typed reader resolves each flip with kill-switch, version
range and install-id-bucketed percent semantics, falling back to the baked value (every flip
inactive) when the block is absent, invalid or never read. No consumer reads it yet.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(telemetry): report Windows security-software refusals and unverifiable or failed runtime checks (#24866)

ssh_remote_runtime_resolved dropped the self-test's security_software refusal to 'none' and
sent nothing when a self-test was unverifiable or failed, because those attempts throw before
a rung settles. Add the refusal value, self_test 'unverifiable', and an outcome field
(resolved | unverifiable | failed) deduplicated per host and outcome per session.

Co-authored-by: m4air <m4air@Mac.localdomain>

* refactor(settings): move WorktreeVisibilityDefaults into its own module (#24884)

global-settings-types.ts sits at the 300-line max-lines ceiling; merging main's two new
agent-state-rules settings with experimentalManagedServers put it at 301. The worktree
visibility defaults type moves next to the other visibility types and is re-exported so its
38 importers are unchanged.

Co-authored-by: m4air <m4air@Mac.localdomain>

* refactor(watcher): move the supervisor's child, terminating child and canary into a child slot (#24887)

* refactor(watcher): move the supervisor's child, terminating child and canary into a child slot

* fix(watcher,runtime): take the child slot's child type from the shared wrapper, and stub main's title-display clear in the projection test

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>

* ci(adhoc): build and ship the orcad template in adhoc macOS and Windows builds (#24969)

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>

* feat(orcad): bound the desktop slot cache and prune proven-stopped orcad versions after each managed deploy (#24973)

* feat(orcad): bound the desktop slot cache and prune proven-stopped orcad versions after each managed deploy

* fix(orcad): keep the in-use slot plus the two most recent others, and prove same-version reuse survives eviction

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>

* Phase 3: managed orcad on every SSH connect, with downgrade-safe fence (#24975, #24979)

* feat(orcad): fence managed hosts outside owner and keep converted projects for downgrades

Shipped builds hide any SSH target with an owner, so a downgrade made a converted host and its
projects vanish. The managed fence moves to a new orcadFence field (older builds keep but ignore
it), phase-3 owner fences migrate on load, and managed hosts stay visible but refuse a direct
relay. Conversion now stops at destination-committed with sourceRetainedAt; source retirement
waits on the baked-off orcad-source-retirement rollout flag. This build hides retained rows, and
a start that finds an older build changed them marks the host sourceChangedAt (relay, needs a
new move) instead of merging a second manifest.

* feat(ssh): every SSH host runs managed orcad, decided on each connect (#24979)

* feat(settings): managed servers are no longer experimental

Remove experimentalManagedServers and its gates; the Managed servers section always shows.
Loading drops a stored value, which an older build reads back as its own default (off).

* feat(ssh): every SSH host runs managed orcad, decided on each connect

Before a relay is started, the connect decides the host's server: a converted host connects
through its tunnel (retiring a retained source once orcad-source-retirement is on), an empty
host deploys orcad, and a host with Orca state converts through the journaled migration. A host
with live or unproven relay terminals keeps the relay this session and converts later. A
refusal for any other reason keeps the relay and names the blocker. A host orcad can't run on
(unsupported target, no template, native preflight, runtime self-test) releases any claim or
fence it took, records why with this app version, and keeps the pinned-relay ladder.

Progress and the decision ride an optional SshConnectionState.managedServer field (dropped by
older clients' admission). SSH Hosts shows each host's server status; the manual move dialog
is removed.

* fix(ssh): always await the connect's server decision, after the provider authority rotates

The decision now runs after the old session and transport are torn down and the authority has
rotated synchronously, so concurrent connects still join one attempt; a shutdown that began
during the decision wins over the rotation. The IPC tests use an async double, no sync branch.

* test(renderer): the IPC events store double carries its SSH connection states

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>

* test(ssh): prove connect-time conversion to managed orcad on real Linux and Windows hosts (#24981)

* test(ssh): prove connect-time conversion to managed orcad on real Linux and Windows hosts, and the downgrade view

* fix(e2e): read the SSH host's session partition, provision the convert cell's account, and keep rollout file overrides out of packaged builds

* test(e2e): report the full relay state when the convert poll times out

* test(e2e): require the relay to list no terminals before the converting connect

* test(e2e): require no running-terminal lease before the converting connect

* test(e2e): report the host's terminal leases when the converting connect keeps the relay

* test(e2e): end the relay era with no relay shell left to respawn

* test(e2e): settle before the converting connect and name the tabs a blocking shell belongs to

* test(e2e): use the exited relay tab as the session tab, since any mounted tab starts a shell

* test(e2e): carry an editor tab through the conversion instead of a terminal tab

* test(e2e): log the conversion census inputs before the converting connect

* fix(orcad): log which state blocked a refused conversion

* test(e2e): log both session partitions before the converting connect

* fix(orcad): a source partition's copy of focus on another host no longer blocks conversion

* fix(ssh): an ssh2 forward on port 0 reports the port it bound, so managed tunnels pair

* test(e2e): give the conversion its full budget again

* test(e2e): report the migration journal phase when the conversion stalls

* test(e2e): report the connect's own result and main's state when the conversion stalls

* fix(ssh): a converted host's managed state reaches the renderer instead of staying on 'converting'

* test(e2e): print a failed server call's response

* test(e2e): give server calls the budget a fresh server's first inventory needs

* test(e2e): read the converted worktree's tabs with a scoped session.tabs.list

* test(e2e): log the converted worktree's tabs instead of asserting them, pending the server-side fix

* test(e2e): prove retirement by the dropped source rows; the journal compacts away after it

* test(e2e): drop the conversion diagnostics now the cell passes

* test(e2e): fail a hung disconnect or connect with main's state instead of the whole budget

* test(e2e): convert an upgraded relay-era profile's host on its first connect, on Docker and Windows

* test(e2e): seed the relay-era target the way addTarget registers it

* fix(ssh): a stale ssh2 forward drops a late connection instead of crashing main on 'Not connected'

* fix(orcad): the active-slot readiness probe reads Windows hosts through the host script

* ci(ssh-windows): let only the convert cell's account open the SSH local forward its managed server needs

* test(e2e): match server paths in their JSON-escaped form, for Windows backslashes

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(orcad): move what an older build added to a converted host, or keep the server's version (#24980)

* feat(orcad): move what an older build added to a converted host, or keep the server's version

A host an older build changed stays on the relay with two actions. 'Move the new projects' runs a
fresh journaled conversion of only what the host's earlier migrations didn't move: the source is
viewed with those migrations retired from it, so nothing that overlaps the server is merged, and
a row the server already holds fails the whole move at stage, before any commit. Its journal
supersedes the chain head; retained-source checks compare against its baseline, and retirement
retires every manifest in the chain only after the newest committed. 'Keep the server's version'
records the current source as the baseline and returns the host to its managed server.

* test(ipc): the runtime environment handler contract lists the delta-move channels

* test(native-chat): snapshot the journal directory after the attach's restart-offer lock is released

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): converted hosts keep their editor tabs, forwarding-refusing hosts stay on the relay, listAll settles (#25099)

* fix(orcad): publish the headless graph so session.tabs.listAll settles instead of hanging

* fix(ssh): a system SSH forward on port 0 picks a free port first and reports it

* fix(runtime): a headless host lists and closes the editor tabs its session holds, so migrated editors reach clients

* fix(ssh): keep a host that refuses TCP forwarding on the relay, and release a conversion it stranded

* test(e2e): assert the migrated editor tab and a settled listAll, and keep a forwarding-refusing host on the relay

* fix: restore the journal import after rebase, type the probe's failure code, and update headless-graph test seams

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server (#25110)

* fix(orcad): client focus and 'local'-stamped tabs no longer refuse a host's move to its managed server

* fix(orcad): a v1.4.218 profile focused on the SSH worktree converts, and a refusal names what blocks it

The debounced session writer never patched activeWorkspaceKey or activeWorkspaceExecutionHostId,
so the first focus stayed on disk. The all-dependency census also counted global focus copies in
every non-source partition.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(ssh): offer to move a host whose open terminals keep it on the relay (#25097)

A host with any open SSH terminal never converted: the connect gate keeps the relay while relay
terminals are live, and an open tab respawns them on every connect. The first such connect per
host per app version now marks the relay status with offerMove (recorded as
managedServerMoveOffered), which toasts "Move <host> ... Its N open terminals will restart." The
SSH Hosts status line keeps a "Move to managed server" action while terminals are live.

Confirming runs ssh:moveToManagedServer: stop the host's relay terminals through the extracted
ssh:terminateSessions path, re-run the connect gate's terminal census, refuse on anything but
exited, then reconnect so the connect-time decision runs the journaled conversion.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(orcad): reach managed orcad over an SSH stdio bridge where sshd refuses port forwarding (#25120)

* feat(orcad): reach managed orcad over an SSH stdio bridge where sshd refuses port forwarding

Hosts with AllowTcpForwarding no were kept on the relay. The managed tunnel now
probes forwarding each time it starts and, on refusal, serves the same local port
through a second provider: each accepted socket opens one SSH exec channel running
a small bridge on the host's pinned Node, which dials orcad's loopback port.
POSIX hosts run it with node -e; Windows hosts run it as the content-addressed
host script's stdio-bridge op with base64 line framing. Bridges are capped at 8
per connection under sshd's MaxSessions default, and a lost channel only drops its
socket. Only a host where even the bridge cannot run keeps the relay, recorded as
ssh_tunnel_unavailable; the older tcp_forwarding_refused record is retried.

* test(e2e): prove the stdio bridge by refused forwarding plus a working call

* test(e2e): connect the refused-forwarding host without the relay-only repo step

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(ssh): report each connect's server decision, and show orcad.log's last lines when setup fails (#25118)

* telemetry: one enum-only event per connect decision (outcome, reason, tunnel transport, host platform, duration), plus conversion start/commit/fail, deploy failures, and the per-host move offer and its result
* errors: deploy, launch and rollback failures carry the last 40 redacted lines of the host's orcad.log, read over SSH (Windows through the node.exe host script)
* SSH Hosts: a deferred or failed setup offers its reason, log tail included, under Details

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(startup): Windows never crashes resolving userData when roaming AppData is unavailable (#25113)

* fix(startup): pin Windows appData and userData before anything resolves them

A Windows session without a loaded profile (e.g. orca serve over SSH) can fail the
roaming AppData known-folder lookup. Electron 43 then falls through to Chromium's
userData provider and crashes natively. Resolve appData first (falling back to
APPDATA, then USERPROFILE\AppData\Roaming), and set userData explicitly so
Electron's provider never runs.

* test(startup): remove the AppData fixture through the retrying helper

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): keep a terminal the previous Orca version's relay still runs instead of replacing it (#25124)

After an app update the new relay answers "not found" for a PTY the previous build's relay still
runs, because the old relay refuses this build's handshake. The client read that as absence: it
expired the lease and the pane cold-restored into an empty shell while the user's shell kept
running, unreachable. Each deploy now takes a census of this target's older relay endpoints; while
one is live or unverifiable, a not-found reattach keeps the lease and the pane binding, and the
pane says the terminal is still running under the previous Orca version. A detached lease also
keeps blocking managed-server conversion until that terminal exits.

The cross-version harness now extracts src/relay, and a new test drives v1.4.218's relay socket and
grace lifecycle with this build's endpoint probe: the probe leaves no grace deadline and reads the
old relay as live work.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(orcad): update a managed server on connect when it runs an older build and is idle (#25122)

* feat(orcad): update a managed server on connect when it runs an older build and is idle

A connect to a managed SSH host now runs the Managed servers update when the host's
orcad differs from this app's bundled build, the template carries the host's target,
and the update planner finds no live or uncounted terminals. A rejected candidate is
restored through the activation journal; the reason is recorded per app version so
later connects don't retry it. A host a newer Orca activated is never downgraded:
the activation record now names the app version behind each build, and an explicit
rollback holds the build it left.

* test(e2e): connect without a racing disconnect after relaunch, and report each attempt

On launch the app already reaches the managed server through its tunnel; a disconnect racing that
restore cancelled the connect that runs the update.

* feat(orcad): run the update check when the launch restores a managed server's tunnel

An auto-restored host may never see an SSH connect, so its server would never update. The tunnel
restore now runs the same check, once per server per session and off the caller's path, through
the shared update-check module; a server mid-migration is left alone.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): retry the pinned Node download through transient network failures (#25154)

A Chromium network change (net::ERR_NETWORK_CHANGED) during the pinned Node
download failed the deploy and sent the host back to the relay. The archive
download now retries up to three more times, after 1s, 3s and 9s, on dropped
connections, timeouts, stalls and retryable HTTP statuses, removing the partial
file first; checksum mismatches, other HTTP errors and cancels stay final. The
transient-error classifier moves from the speech download to
src/main/network/transient-download-error.ts so both share it.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(serve): run orca serve on orcad by default, with ORCA_SERVE_RUNTIME=electron as the opt-out (#24972)

* feat(serve): run orca serve on orcad by default, with ORCA_SERVE_RUNTIME=electron as the opt-out

* test(serve): read Electron serve's pretty-printed readiness in the CLI mode-switch e2e

* feat(serve): gate D7 on Windows and serve on orcad there by default

* test(serve): take the profile lock in the CLI mode-switch e2e, and keep Windows profile logs on failure

* test(serve): tell Electron and orcad serve apart by readiness health, and trace Windows startup

* ci(e2e): dump Electron's native log and stack on the Windows serve mode-switch job

* fix(serve): keep Windows on Electron serve until it can adopt orcad's daemon

The Windows D7 job shows Electron serve exiting before its window when it relaunches
onto a terminal daemon orcad forked. Restore the win32 fallback and skip that case
there as a known gap; the follow-up PR fixes it and re-flips Windows.

* fix(serve): let ORCA_SERVE_RUNTIME=orcad opt in on Windows while Electron stays the default

* test(serve): skip the Windows D7 cases where orcad forks the daemon, and stop cleanup hiding a failed relaunch

Test 3 hits the same Windows gap as test 2: orcad forks its own daemon there, and Electron
crashes at startup beside it. A failed relaunch also made dispose close the old, already
closed app, whose throw replaced the launch error.

* test(serve): run every Windows D7 case, with the isolated home's AppData in place

Electron 43 crashes natively (0xFFFF7003) when it resolves userData and Windows cannot find
roaming AppData. The e2e home isolation points USERPROFILE at a fresh folder with no AppData,
so later launches hit that. The harness now creates it, every D7 case runs on Windows again,
and a new case proves Electron serve starts beside another profile's live orcad daemon.

* test(serve): retry removing a Windows e2e profile while a killed daemon releases it

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(ssh): resume a terminal the previous Orca version's relay still runs, through that relay's own bridge (#25170)

* feat(ssh): resume a terminal the previous Orca version's relay still runs, through that relay's own bridge

After an app update the previous relay keeps the user's shells alive but refuses this build's
handshake. Its own relay.js --connect, run from its own version directory, presents its own bundle
hash, so on POSIX hosts the client now reaches it that way: a pane whose reattach the current relay
held for an older relay opens a route through the old bridge, takes the PTY owner role without
output flow control, reattaches the PTY with its replay, and routes every later operation on that
id to the old relay. When the last pane a route serves exits, the route hangs up and the old relay's
own idle grace retires it. Windows hosts, relocated short sockets and unreachable bridges keep the
held-pane behaviour.

The cross-version harness now builds v1.4.218's relay from its tagged sources and runs it as the
real detached daemon: a shipped client leaves a shell in it, this build resumes the pane through the
old bridge, types into it, sees its output, and watches the old relay exit on its own after the
shell does.

* fix(ssh): read the legacy relay router through its instance

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a host re-upgraded after a downgrade can move what the older build added (#25179)

The delta view left the moved projects' session state in place whenever it held anything
unmovable, so it then counted against the delta. Relay PTY bindings, shutdown markers and the
relay consumer's recovery record blocked every move although the terminal gate already proves
those terminals exited before any move commits; the move now drops them instead.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(worktree-ps): report a lost-contact host's terminals as unverifiable, not live:0 pty:no (#25167)

During a network drop to a relay-served SSH host, every PTY on it reads as an
unconfirmed exit, so worktree ps skipped them and printed live:0 pty:no for a
terminal that was still running. The listing now counts terminals whose liveness
verdict is unverifiable into a new optional unverifiableTerminalCount, and the CLI
prints live:unverifiable pty:unverifiable (JSON carries the same word) instead of
zero. A host-confirmed exit still reads as zero.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(terminal): a held pane shows no client OS or shell, and the boundary doc says POSIX hosts resume it (#25194)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): tunnel to the port managed orcad bound and verify it is ours (#25182)

When another Orca already listens on 6768, orcad binds a different port. The
tunnel kept forwarding to 6768, reached the other runtime, was rejected with
4001, and the connect still reported a managed server.

The tunnel now reads orcad's bound port from its active slot's readiness
(falling back to the persisted port for slots without one) and, after the
forward is up, proves the server answering is the paired runtime. On a
mismatch it re-reads the port once and fails with orcad_identity_mismatch
instead of reporting managed.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a converted host shows only its managed server's rows, and its old editor tabs load there (#25193)

Retained relay-era project groups and the per-host SSH catalog now hide like repos and folders.
On the managed transition the renderer reloads server names, groups, folders and worktrees, then
drops the host's relay-era rows a local refresh would keep. A restored tab with no host stamp in
a workspace now owned by a managed server takes that server as owner, in place.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

* fix(orcad): ship the port-scan worker so managed servers detect workspace ports (#25197)

orcad resolves port-scan-command-worker-entry.js beside orcad.js, but the orcad
build never emitted it and ORCAD_ARTIFACTS never listed it, so every managed
server logged 'probe worker unavailable' and had no port detection. The build now
emits it with the other children, and the artifact list carries it, so it is
uploaded, hashed and covered by the template contract.

A new test bundles orcad and its children and fails when the bundle names a
worker or child entry the slot does not ship. Two existing gaps it found,
session-scanner-service-entry.js and wsl-transcript-fs-process-entry.js, are
listed as known and may only shrink.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): keep retrying when a reconnect loses the socket before the SSH banner (#25195)

After a network outage, a port forwarder or NAT can accept the TCP
connection and then close it before the server sends its banner. ssh2
reports that as 'Connection lost before handshake' with no errno, so the
reconnect ladder classified it as permanent and published 'error' with
no retry scheduled. Treat it as recoverable on the bounded ladder only;
the initial connect keeps its narrow classifier.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(serve): serve on orcad by default on Windows too (#25162)

D7 now runs every case on Windows (orcad-serve-mode-switch-windows), including Electron
serve adopting a daemon orcad forked, so Windows no longer needs the Electron default.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): the terminal gate asks the relays before treating a detached terminal as running (#25200)

* fix(orcad): the terminal gate asks the relays before treating a detached terminal as running, and retiring a host drops its relay recovery record

* fix(ssh): earlier-relay census gaps and an unanswered relay stay unverifiable; asking the terminal gate changes nothing

- the gate is read-only; the conversion and delta move retire proven detached leases themselves
- an expired lease also needs every earlier-build relay to answer before it reads as exited
- a failed, input-less or truncated census marks its older relays unverifiable, so reattach holds
- a legacy relay route closes only when no attach or listing still awaits it
- a disposed session forgets the census it started

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): recover interrupted conversions and delta moves, and trim dead migration code (#25226)

* refactor(migration): drop the unused full dependency census

* refactor(migration): one table for the routed UI fields a migration carries

* refactor(migration): share the session focus field list and fix stale claim comments

* fix(session): keep a fenced SSH host's source session partition intact through renderer saves

The renderer cannot see a fenced host's repos or folders, so hydration drops their
worktree rows; a save that still routes any row to ssh:<target> (a folder workspace
key stays valid) rewrote that partition without them. That changes the migration's
source manifest and loses the session a downgraded build reads back. Main now
ignores renderer writes to a fenced, unchanged host's partition.

* fix(migration): resume or back out unfinished conversions and delta moves, serialize delta moves, clean stale journals

- On connect, a registered server whose conversion never committed resumes the commit; a failure aborts it through the destination, unregisters the server and releases the fence.
- An interrupted delta move keeps its journal and gets its changed mark back; the next move resumes it from the journaled manifest or aborts it when the source has moved on.
- Delta moves run under the target lifecycle queue and re-check the head, so two concurrent moves cannot write two journal heads.
- Delta checks re-read the live source instead of a frozen copy.
- Stale journals from a stopped or never-registered server are cleared.
- Retiring a chain goes newest first and compacts only at the end.
- The journal schema tolerates fields a newer build adds.

* fix(migration): hide source rows only after commit, and retire what an older build added to a moved project

- Source rows and the renderer session guard share one rule: hidden once the server committed, shown while a migration is still in flight.
- A delta move a crash interrupted gets its changed mark back on the next start.
- Retirement removes worktree metadata and automations by moved-project scope, so an older build's additions no longer fail the leftover check forever; that check ignores rows of other migrations in the chain.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): survive a slow first daemon start and close review gaps (#25213)

- Give the terminal daemon 30 s to start on Windows and retry the spawn once before
  falling back, so a cold first deploy is not refused as daemonless.
- Say in the activation refusal that orcad.log holds the daemon's error and that the
  next connect retries.
- Managed stop completion now waits out daemon retirement plus the shutdown deadline.
- Publish the instance lock atomically and reclaim an abandoned torn lock.
- Fix the stop listener closing before it was defined on an already-present request.
- `orca serve`'s cache prune keeps the slot a running local orcad uses.
- A timed-out daemon retirement reopens admission once it is refused; a retirement that
  may have reached the daemon stays fenced.
- A headless host keeps an editor tab's unsaved draft unless the close is forced.
- Unverifiable terminals are attributed by the host's PTY record, like live ones.
- An explicit --user-data-dir is no longer overridden on Windows.
- Read Windows daemon process identity from the process table, not PowerShell.
- Remove the unread data-event incarnationId and daemon health runtime fields, share
  one process-alive and error-code check, and fold the websocket limits file back.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(cli): stop, update, roll back and recover managed Orca servers from the CLI (#25201)

* feat(cli): stop, update, roll back and recover managed Orca servers from the CLI

orca environment status|update|rollback|recover|stop|cancel-stop call the same managed-server
actions as Settings > Managed servers, over new managedServer.* runtime RPC methods. The desktop
main process registers those actions; the runtime advertises managedServer.v1 only then, and the
CLI refuses on a runtime without it or one that answers method_not_found. stop requires --yes.

* fix(ipc): keep a missing managed-server selector a rejection, not a synchronous throw

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): Move to managed server converts the host it is offered for (#25196)

The move stops the relay terminals and passes the terminal census; with #25179 the conversion no
longer refuses on the stopped terminal's saved tab, layout leaf and pane incarnation. A new test
drives one live relay terminal through Move to a conversion whose manifest carries the tab without
its relay PTY, so it spawns a fresh shell on orcad.

ssh:terminateSessions also no longer records a not-found shutdown as terminated while an older
Orca build's relay may still run the PTY (#25124): that terminal is reported unverifiable and its
lease kept, so the move refuses instead of converting over a running shell.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): deploy the glibc 2.17 runtime on old-glibc hosts, and fall back to the pinned relay (#25180)

* fix(orcad): deploy the glibc 2.17 runtime on old-glibc hosts, and fall back to the pinned relay

Managed orcad picked its runtime from the host's libc flavour alone, so a CentOS 7 host
(glibc 2.17) got the default linux-x64-glibc Node, whose self-test fails there. The deploy
now picks the runtime by glibc the same way the relay ladder picks rung A or B, and the
host-side slot checks accept the compat target it ships.

When orcad still can't run, the host is recorded as such and the relay connect that
follows now runs the pinned-runtime ladder instead of defaulting to the host-Node relay,
so a host with no Node lands on rung B rather than failing.

The hostile-host lane deploys managed orcad on a fresh CentOS 7 host and asserts it runs on
the compat runtime with no default runtime uploaded.

* test(ssh): CentOS 7 cell asserts the compat runtime pick, then the refusal and relay rung B fallback

The compat template still ships the base @parcel/watcher binary, which needs GLIBCXX_3.4.20; CentOS 7's
libstdc++ stops at 3.4.19, so the candidate's preflight refuses. The cell now pins that end-to-end
behaviour: compat runtime picked and uploaded alone, refusal classified native_preflight, and the relay
that follows settles on rung B with no runtime setting.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(serve): prove D7 on an installed Windows app whose daemon runs from the relocated daemon-host (#24976)

* test(serve): prove D7 on an installed Windows app whose daemon runs from the relocated daemon-host

* test(serve): read the pre-switch scrollback best-effort and wait for it after reattach

* test(serve): opt the packaged Windows serve switch into orcad explicitly

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): a retirement that fails after deleting rows resumes instead of reading as changed (#25234)

The chain head records sourceRetiringAt before any row is retired; the startup change check and the chain's own comparison skip a head that carries it.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(orcad): managed orcad idles out after 15 minutes and is started again whenever it is down (#25121)

* feat(orcad): a managed orcad stops after 15 idle minutes and starts again on the next connect

A client-launched orcad now exits, like the relay, once no client, terminal,
working agent, staged migration or activation fence has been seen for 15
minutes. The exit is the normal graceful shutdown, which leaves the terminal
daemon running; the daemon retires only if it proves itself empty. A record in
the data root tells the next start (and its readiness health) that the stop
was an idle one rather than a crash.

On connect and after host resume, a fresh tunnel whose server does not answer
starts the activated slot under the activation fence, but only on a proven
exit, so a stopped server reads as not running rather than a failure.

* fix(orcad): keep orcad-entry under max-lines; idle e2e connects without a relay repo

* fix(orcad): deploy and rollback launches carry the managed idle-exit fence

The candidate launch in activation and the rollback launch built their
own launch spec without the activation root, so a freshly deployed orcad
never enabled idle exit; only the wake path did. The field is now required
on every launch spec, so the type system covers each launch site.

* feat(ssh): start a stopped managed orcad on connect, on restore and after resume

Once orcad stopped (idle, kill or host reboot), a connect still resolved
managed over a forward to a dead port and every call failed. Every connect
now checks the server behind its tunnel, as does a call through a restored
environment; a server proven stopped is started from its activated slot
under the activation fence, adopting a surviving daemon and its terminals.
The status line shows the start, and a start that fails keeps the host
managed with the reason and orcad.log's tail, never as a terminal verdict.

* fix(ssh): reuse a serving verdict only on the same SSH transport, for 5s

A reconnect right after a reboot was answered from the previous
transport's cached verdict, so the stopped server was never started.

* fix(ssh): key the serving verdict on the tunnel's remote port too

* test(ssh): a stopped server starts before the update counts its terminals

* feat(ssh): check serving at the bound port, and follow a restarted orcad to a new one

The serving check uses the port the tunnel forwards to (the one orcad bound).
A restart that binds a different port drops the forward and rebuilds it at
the new port, within the same ensure or on the explicit connect check. The
tunnel manager class moves to its own file to stay under max-lines.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a connect with no leases asks the host's relay endpoints before converting (#25223)

* fix(ssh): a connect with no leases and no relay session asks the host's relay endpoints before converting

* refactor(ssh): one isLiveSshPtyLease for every lease-liveness check

* fix(ssh): the host relay census asks a relay its PTYs before reading it as live work

An accepting relay whose holders or children the probe could not read (no lsof, an unrecognised
service child) read as live work, so a relay-era host that had exited its last shell never
converted. The relay's own bridge answers pty.listProcesses without the owner role.

* fix(ssh): the host relay census runs each relay's probe and bridge on the runtime it runs on

Pinned-ladder hosts often have no Node on PATH, so a PATH Node read every relay as unverifiable.
Each daemon's own argv names its pinned runtime, or the host Node a legacy relay started with.

* fix(ssh): an older relay's bridge runs on that relay's own runtime

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(orcad): build @parcel/watcher into the glibc 2.17 compat slot, so CentOS 7 runs managed orcad (#25199)

The compat target swapped in only node-pty, so it shipped the base target's upstream
watcher.node, which needs GLIBCXX_3.4.20; CentOS 7 stops at 3.4.19. orcad's preflight
refused it, and relay rung B lost file watching without saying so.

The compat slot now compiles @parcel/watcher from the package's own sources against the
pinned headers with the C++ runtime static, like node-pty. The slot gates (glibc 2.17
symbol floor, no shared libstdc++, N-API 8) and the smoke load cover it, the template
stages it into the compat target, and both orcad and relay rung B pick it up from there.

COMPAT_SLOT_ADDONS names a compat slot's own addons: a compat slot missing one fails
--require-slots, and a compat template target that would ship any native file without a
compat build fails the template build. The CentOS 7 cell expects an activated managed
server again, and every launched relay cell loads its watcher directly.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(phase3): close e2e routing holes, localize managed-server outcomes, trim harness and CI (#25206)

* fix(phase3): close e2e routing holes, localize managed-server outcomes, trim harness and CI

- Route the auto-convert spec into needs_build and route the new orcad/serve
  e2e helpers to the specs that use them, with routing tests.
- Run the auto-convert Docker lane only when routed; fold the missing-AppData
  check into the Windows mode-switch job and drop crash-hunt diagnostic env.
- Delta-move dialog keys its preview on the target id and cannot close or
  resubmit mid-move; Resume has an in-flight guard.
- Managed-server toasts and status lines show localized messages instead of
  raw codes or main-process English; add singular and zero-count wording.
- Settings style fixes (quiet Cancel, section header, labelled fields,
  progress labels); delete dead i18n keys and the unused previewConversion.
- Harness: kill the serve child on readiness timeout, share the isolated
  profile and spawn-until-ready helpers, hooks and a condition wait in the
  auto-convert spec, shared cross-version exec helper.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(phase3): localize the refusal toast, share serve readiness and liveness helpers, correct D7 docs

- The refusal toast no longer shows main's English blocker detail; the SSH
  Hosts status line keeps it under Details. A missing terminal count is no
  longer defaulted to 0.
- startOrcadServe uses spawnUntilReady, so a readiness timeout kills orcad;
  one isPidAlive replaces the spec's copy and the lock-holder loop.
- The port-6768 auto-convert test uses the shared hooks.
- The docs say the Windows D7 job checks rather than gates.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ci): give the idle-exit spec its app build and route the convert harness to it

Co-authored-by: m4air <m4air@Mac.localdomain>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* ci(adhoc): pin every job of an adhoc build to the commit resolved at dispatch (#25311)

Each job read the requested branch name and checked out whatever it pointed at when that
job started. A push mid-run mixed commits: in run 37234548638 the glibc217 slot lane
built 2ddea8736e, then #25199 merged, and desktop_template checked out 70948d597f, whose
merge step requires the compat watcher that lane never built.

A first resolve-ref job (no secrets) resolves the branch, tag or full SHA once, and every
job checks out that commit. The mac job still vets it for reachability before signing;
the requested name stays the concurrency key and the release label.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(terminal): report a confirmed relay PTY exit as exited and give a cold Windows PTY probe more time (#25304)

A worktree terminal close returned as soon as the relay confirmed the stop, but the
exit frame reaches the runtime record only after the SSH output intake drains, so the
verdict read straight after saw a still-connected PTY and answered unverifiable. The
stop now waits, bounded by its deadline or 10 s, for that record.

The bundled runtime's PTY probe gives the first Windows spawn 15 s and retries once
after a timeout only; spawn errors and non-zero exits still fail at once.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(relay,orcad): keep new wire messages forward compatible, drop unshipped relay.reset, fix shutdown and serve gaps (#25298)

* fix(orcad-migration): keep migration wire forward compatible with newer peers

* docs(pty): record why pty.resumeClient negotiates by method-not-found

* fix(relay): remove the unused relay.reset method and keep a deferred shutdown serving

* refactor: drop dead hold API, release gate and migration pass-throughs; fix serve and delegation gaps

* fix(lint): keep reopen hooks within file size limits

* revert type dedupe in orcad-incumbent-recovery to avoid a parallel conflict

* fix(lint): name stop-reply request fields for their role

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* refactor(ssh): one managed-tunnel ownership check and forward bookkeeping (#25316)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a stuck managed server can recover, cancels finish their change, unknown stays unknown; delete unused reset/drain code (#25227)

* refactor(ssh): delete the unused connection reset and drain machinery

* fix(ssh): surface a stuck managed server, finish activations past their first change, and fall back from Windows launch refusals

- A rejected build that changed profile state now refuses as orcad_recovery_changed_state
  (unverifiable) with a Recover path that restores the snapshot once the operator accepts;
  an interrupted activation is no longer reported as a quiet update deferral.
- Activation and rollback drop the abort signal after their first journaled change.
- Windows launch refusals and unsafe command lines send the host back to the relay.
- Fence refusals keep unverifiable blockers unverifiable; the POSIX liveness probe reads
  kill errors in the C locale and treats permission errors as unknown.
- A host with no relay fallback surfaces its real connect error; disconnect and removal
  clear the setting-up status, and a cancelled decision's progress is dropped.
- Remove tcp_forwarding_refused leftovers, dedupe incumbent stop/restore, exec-or-empty,
  census client, errorMessage and the blocking-blocker predicate; split activation and
  snapshot files under 300 lines.

* fix(ssh): one import per module in the rollback transition

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): committed reads trust the receipt, conversions survive UI churn, journals are cached, and round-2 low items (#25327)

* fix(ssh): keep a newer build's fence and note fields, and validate a fence beside a legacy owner

Normalization now validates known fields but passes unknown ones through on orcadFence and the managed-server notes. A legacy managed owner next to a malformed fence falls back to the owner's environment id instead of keeping the malformed fence.

* fix(migration): delete a retired migration's scrollback files once retirement is durable

Retirement dropped the moved terminals' scrollback refs from session state but kept the files forever. After the journal records source-retired, the files the manifest names are deleted, except refs any session partition or pending export still names. A crash before the delete repeats it on the next retirement pass.

* fix(orcad): a committed migration reads as committed from its receipt, survives receipt eviction, and abandoned stages expire

- Committed-state reads trusted only a byte-identical copy of every moved row and snapshot, so a live server that had been used could never confirm its own commit. The receipt alone now proves it; full equality stays inside the commit.
- A receipt that ages out of the 64-entry list keeps a compact record, so the commit never reads as absent.
- A stage no client returns to within a week stops holding the server awake and is dropped at the next stage.

* fix(migration): freeze a host's session from the fence on, ignore UI churn in the frozen-source check, and cache parsed journals

- Renderer writes to a fenced host's ssh: partition are skipped from fence time, not only after commit, so tab work mid-conversion cannot change the source.
- The frozen-source check leaves out workspace session and client routing state, which the UI rewrites as the user works (including through the local partition and UI state); the server gets them as journaled.
- Parsed journals are cached per directory, keyed by each file's inode, size and mtime and dropped on every write and remove, so list and session calls stop re-parsing every manifest.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): an older relay a census cannot rule out stays held, and a reconnect resumes held PTYs through it (#25331)

- A census that does not know the host platform is unverifiable, never "no older relay". On Windows
  it now probes each older version directory's pipe for the target, the way relay GC does, so a
  live older Windows relay keeps its PTYs held instead of respawning their panes; those endpoints
  are held, never bridged.
- On reconnect, a PTY the current relay disowned while an older relay holds it is reattached through
  that relay's own bridge, with its runtime restored and its replay forwarded. When no route serves
  it, it is left for recovery like an exhausted reattach.
- A superseded or disposed deploy no longer starts the census, so it cannot replace the current
  attempt's.
- A route stops holding an unserved PTY that exits.
- Drop the unused censusPreviousRelays export.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(phase3): round-2 ui-infra review fixes (#25325)

- An unverifiable move refusal with no count says so instead of "0 terminals".
- One move per host: the dialog stays mounted while open, joins a run already
  in flight on remount, and the toast shares the same guard.
- A managed server's start, wake or update no longer reloads every host's
  catalog; only a new environment for the host loads, scoped to that host
  and the local catalog.
- A failed host-partition session write is no longer acknowledged as written,
  so the writer re-queues those fields.
- The deploy picker lists only hosts with no managed server or pending move.
- Harness: orcad and the released relay daemon are stopped when startup fails.
- Windows host CI: the convert cell always runs last and fails on a failed
  native switch or build; ssh-host-server unit tests no longer trigger it.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(relay,cli): never report an unreachable terminal as exited; keep PTYs through a deferred shutdown (#25328)

* fix(relay,cli): never read an unreachable relay or terminal as exited; keep PTYs on a deferred shutdown

* fix: stop an unrecorded breakaway child, route orcad serve through the spawn chokepoint, and drop review-flagged leftovers

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): reclaim a crashed desktop lock, close the stop-cancel race, drop dead daemon and RPC code (#25338)

* fix(orcad): reclaim a crashed desktop lock, close the stop-cancel race, drop dead daemon and RPC code

- A desktop reclaims a stale desktop lock record (Electron's lock proves it), and a real
  orcad hold shows a dialog instead of exiting silently.
- A failing quit handler no longer skips closing observability.
- Completion withdraws its request when a cancel lands mid-write; the listener removes a
  cancelled leftover.
- An idle stop's clean record is retracted when the shutdown fails or overruns.
- The managed-stop request tolerates unknown fields from a newer client.
- Shared error-code checks, one win32 coverage rule, no redundant isAlive filters.
- Remove the recovery-only daemon provider, requirePinnedWsPort/strictPort and the
  superseded orcad.migration.importCatalog RPC.

* fix(startup): keep main-process-preflight under the line cap

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): fail fast on a held fence, restore untouched rollbacks, atomic Windows host script, honest tunnel ensure (#25332)

* fix(ssh): fail fast on a held activation fence, restore a rollback the target never touched, stage the Windows host script atomically

- withOrcadActivationLock no longer waits up to 15 minutes inside the target lifecycle: a held
  fence answers at once (orcad_activation_recovery_required); deploy probes it before uploading.
- A rejected rollback target that left the restored snapshot untouched puts the newer build back
  unasked; only a real change waits for the operator. Crash recovery applies the same rule.
- The Windows host script is written only when missing, through a partial file and a rename; a
  bridge exit without a sentinel is unverifiable unless the shell could not find the command.
- The managed tunnel's ensure() throws when superseded, and a caller arriving after close()
  builds a fresh run instead of joining the doomed one.
- An unparseable stop answer after a stop was sent keeps the fence (new 'unconfirmed' outcome).
- stopRemote starts over instead of reporting live when another run settled the journal.
- Releasing an unreachable setup stops the orcad it activated and clears active on proven exit.
- A failed startup is torn down quietly so its error stays published; port-forward listeners
  keep an error handler; a linked SSH access connect is cancelled when its window closes.
- Shared errorMessage, one SFTP transfer helper, getConnectGeneration only, test-cell names.

* fix(ssh): stage the Windows host script through the pinned node.exe, which cmd.exe and PowerShell both run

* fix(ssh): host-script staging runs as host-script ops; a joiner of an overtaken tunnel run builds its own

- The presence check and the install are ops in the uploaded host script (script-present, and
  script-install run from the partial upload itself), invoked as node.exe <script> <op> <args>
  like every other Windows host op: no inline code.
- ensure() callers that joined an in-flight run no longer inherit its 'superseded' end; they
  start a fresh run, which also covers a close() in between.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a connect re-checks unverifiable relay terminals once its relay session can answer (#25385)

The server decision runs before any relay session exists, so a terminal this desktop left
detached could never be asked about and read unverifiable for as long as it ran. On Windows no
endpoint census can fill that gap. Once the session is up, the relay that holds the PTY answers
and a terminal it still runs is reported live.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(settings): a managed server whose status never loaded reads unknown, not "Not running" (#25389)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(relay,runtime-env,cli): keep a deferred relay's AI Vault and skill uploads, make managed re-pair crash-safe, show unverifiable in worktree ps text (#25393)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): keep a surviving daemon's slot through the cache prune, and drop an idle record a signal stop took over (#25387)

- The slot prune also protects any slot a live terminal daemon's PID record points into, and
  evicts nothing while a daemon record is unreadable.
- The shutdown trigger reports whether it took ownership; an idle stop that another source
  took over discards its clean-idle record.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): edit a managed host's connection, show rows no migration owns, drop the unused quit-drain predicate (#25403)

- SSH settings may edit a managed host's connection fields; its fence and generation are never written from the renderer, and its tunnel is closed so the next use redials. Removing it is refused with a pointer to Stop under Managed servers, which stops and removes the server; the renderer no longer ends the host's terminals before that refusal.
- A fenced host with no journal (an empty host's deploy, or one whose journal compacted after retirement) no longer hides its rows: no committed move owns them, so projects an older build added stay visible. An unreadable journal still hides them.
- The quit drain's mayDetach predicate and disconnectAll's filter had no production caller and are removed.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): input to a terminal an older relay holds but no route serves is refused, not dropped (#25404)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): refresh a changed host's status once its delta move or keep-server choice lands (#25416)

The status line kept saying the host was changed on an older Orca until a manual reconnect. Main now publishes the host as managed by its server after a successful move or keep, with a disconnected state when the move released the relay session.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a stopped managed server stops showing on its SSH host without a reconnect (#25409)

* fix(ssh): a stopped managed server stops showing on its SSH host without a reconnect

On unlink, forget the host's managed-server decision and republish its connection state. The
republish also drops the host's cached worktree scans, so worktree.ps stops naming the removed
server.

* fix(runtime-environments): a removed server's workspace session partition goes with it

Host-scoped listings enumerate session partitions as known hosts, so worktree ps kept naming a
stopped or removed server as an omitted, unselectable host. Unlinking or removing a server now
drops its runtime:<id> partition.

* test: give the removal-storage fake store casts their SAFETY rationale

* fix(runtime-environments): drop orphaned runtime workspace sessions at startup

A crash between unlinking a server and dropping its session, or a build that unlinked before the
drop existed, left a runtime:<id> session listings name as an unselectable host. Startup now drops
runtime sessions whose server is not in the environment store; it never touches local or ssh
sessions, and skips entirely when that store is missing or unreadable.

* test(migration): retirement re-aims focus without creating the destination's session

Locks in the ordering the startup reconcile relies on: a runtime:<id> session is never written
before that server is registered.

* docs(runtime-environments): name the ordering invariant the startup reconcile relies on

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a connect whose relays prove its leases ended retires them, so the next connect converts (#25405)

Every connect decides before a relay session exists, so a detached or expired lease reads
unverifiable there. The post-session re-check proved such leases ended but left them in place, so
the host stayed unverifiable on every connect and each respawned pane added another lease.

Co-authored-by: m4air <m4air@Mac.localdomain>

* ci(ssh-windows): check out preload and renderer for the orcad-convert e2e build

Main's #25359 narrowed this workflow's checkout to the server and test trees. Phase 3's
orcad-convert cell builds the full e2e app with electron-vite, which also needs src/preload
and src/renderer, so the x64 inbox cell failed with UNRESOLVED_ENTRY.

* test(orcad): model a really converted host in the v1.4.218 downgrade wire test

#25403 shows a fenced host's rows when no migration journal explains the fence. The downgrade
test fenced the host without a journal, so this build showed the retained project it is meant
to hide. Write the destination-committed cutover journal a real conversion leaves.

* fix(ssh): a briefly held fence waits and is retried, not recorded as a failed update; terminate keeps held leases (#25420)

- The activation fence is retried for a few seconds. A fence still held answers
  orcad_activation_fence_busy (a waiting deferral, never an update failure) unless a journal or
  a lock past its stale age shows an interrupted run, which stays recovery-required.
- acquireInstallLock reports Busy only when a holder answered; a lock command that only ever
  failed surfaces its own error.
- Terminate detaches instead of disposing when any PTY was unverifiable, so the final teardown
  never bulk-marks a lease an older relay may hold as terminated.
- A wake whose connection dropped while holding the fence releases that fence on this client's
  next wake (no journal, slot proven exited), so a relaunch-then-connect is not left fenced.
- The orcad e2e reconnect helper surfaces a connect's error text instead of a JSON parse error.

Co-authored-by: m4air <m4air@Mac.localdomain>

* ci(ssh-windows): check out all of src for the orcad-convert cell

Its e2e global setup also compiles the bundled CLI from src/cli, which the narrowed checkout
left out.

* fix(orcad): idle stop — drop the record when a signal stop wins, and read the activation lock, not its root (#25464)

* fix(orcad): drop the idle-stop record when a signal stop finishes first

Every stop's cleanup now discards the record unless the idle trigger owns the stop, so a
takeover that exits before the idle request runs no longer leaves a false idle stop.

* fix(orcad): idle check reads the activation lock, not the transaction root

An interrupted acquire can leave the root without a lock; the client already treats that as
unfenced, and managed orcad now does too instead of never idling out.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): main refuses a managed server host's removal before ending its terminals (#25465)

The remove flow skipped ending terminals for a managed host only when the renderer's cached target list already showed the fence, so a fence that landed after the list loaded still lost the host's terminals before main refused the removal. The removal's terminate call now carries forRemoval, and main refuses it for a managed host with the Stop… message before touching anything; the renderer no longer decides.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(terminal): closing an SSH workspace counts a host-confirmed terminal exit as stopped (#25479)

* fix(terminal): a workspace close counts an SSH terminal's confirmed exit as stopped

The close's verdict treated any SSH terminal whose record was still present as
unconfirmed, even when that record held a host-confirmed exit. SSH records outlive
their exit, so every successful close of a relay terminal answered unverifiable.
Also, a relay reattach that finishes registering after the stop no longer revives an
incarnation whose exit is already recorded.

* test(terminal): cover a reattached relay exit confirmed without an incarnation

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(cli,ssh): managed-server actions outwait their own deadlines; cap the unverifiable serving detail (#25473)

orca environment update/rollback/recover/stop/cancel-stop waited the 60 s RPC default while the
desktop runs the whole action inline, so a slow host printed a timeout failure for an action that
kept going. They now wait 20 minutes, and a timeout says the action may still be running and points
at `orca environment status` instead of reporting failure. status keeps the default.

The retained managed-server state now clamps serving.detail to the same byte limit as its sibling
detail fields.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): keep the previous orcad.log on Windows across a restart (#25481)

The Windows breakaway launcher's addon recreates orcad.log on every start, while POSIX
appends, so a crash's log was gone once orcad restarted. The orcad launch now asks the
launcher to move the last run's log to orcad.log.1 first, capped to its last 1 MiB.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(sidebar): a stopped managed server leaves no empty project group behind (#25488)

The removed-runtime purge dropped the server's repos, setups and worktree rows but kept the project
groups and folder workspaces the renderer fetched from it, so an empty heading stayed in the
sidebar. It now drops those runtime-stamped rows too.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): run automations on orcad and keep a managed host up while they can fire (#25475)

* fix(orcad): run automations on orcad and keep a managed host up while they can fire

orcad never built an AutomationService, so with orca serve defaulting to orcad scheduled
runs never dispatched and Run now threw runtime_unavailable. The headless service setup
moves out of Electron startup into automations/runtime-automation-service.ts; orcad
installs, binds, starts and stops it, and managed idle exit counts an enabled schedule or
an unsettled run as busy.

* docs(orcad): list automations among the idle-exit conditions

* fix(automations): precheck reads the SSH manager from its registry, keeping electron out of orcad

The precheck imported getSshConnectionManager through ipc/ssh, which pulled 32 electron
modules and node:sqlite into the orcad bundle and failed build:orcad.

* test(orcad): stub the automation wiring in the push-startup runtime harness

That harness stubs OrcaRuntimeService without an automation surface, so starting the real
service threw setAutomationService is not a function and timed out the next test.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): Move to managed server survives a relay that hangs up on its last terminal exit (#25487)

BUG-15: with two live terminals, one served through an older build's relay, Move failed with
"Failed to terminate SSH host sessions: …: Multiplexer disposed" although both shells died. The
old relay reports the exit before the shutdown reply; that exit closes the route (its last served
PTY), and the disposed mux rejected the shutdown still awaiting its reply.

- The legacy relay route settles a shutdown whose PTY exit it already observed.
- Move no longer aborts on a failed stop: the terminal census (what the conversion trusts) decides.
  Exited closes the relay session and converts; live or unverifiable refuses and republishes the
  relay status, so the stale terminal count is replaced.
- The move dialog offers Try again after a refusal or failure.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): closing a terminal a previous Orca relay runs stops it there and confirms the exit (#25471)

A stop on a PTY an older relay runs reached the current relay whenever no route served it at that
moment (after a reconnect with the pane unmounted, or a shell no pane ever resumed), and the current
relay answers a stop for an id it never minted as done, so the PTY read stopped while its shell
kept running. A stop on a served PTY failed instead: its exit arrived before the stop's reply,
closing the route under the pending request.

- A served PTY stops on its route; a PTY no route serves is stopped through a short-lived route to
  the older relay that lists it, which hangs up once that PTY exits.
- When an older relay may hold the PTY but cannot be asked (incomplete census, Windows pipe, a
  bridge that will not open), or its bridge drops mid-stop, the stop is unverifiable, never reported
  done; terminate keeps the lease.
- A route stays open until its in-flight requests settle.
- The provider's exit stream includes the exits older relays report, so a stop observes the PTY it
  stopped exit on the relay that ran it.

The cross-version harness runs what terminal close --all runs per PTY against a real v1.4.218 relay,
for a pane resumed this connection and for a shell no pane resumed, and sees the old shell exit there
and the old relay retire on its own grace.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a live run's journal reads busy, a wake releases only its own fence, a cancelled re-check never reports connected (#25470)

* fix(ssh): a live run's journal reads busy, a wake releases only its own fence, a cancelled re-check never reports connected

- A held fence asks for Recover only when its lock is stale or a journal has no fence over it;
  a journal under a fresh fence is a run still working and reads orcad_activation_fence_busy.
- A wake writes an owner token into the fence it takes and later releases only a fence carrying
  that token; observing the fence gone forgets it.
- The connect re-checks ownership after the relay-terminal re-check, and the re-check itself
  neither records a decision nor retires leases for a cancelled attempt.

* fix(ssh): a tunnel caller that joined a run a disconnect cancelled builds its own

The launch-time restore's tunnel run connects over SSH; a disconnect then cancels that connect.
An explicit connect that had joined the run inherited its SshConnectAttemptCancelledError and
failed (seen as the idle-exit e2e's 'connect threw: ... cancelled'). A joiner now builds once
anew after any end of the joined run except an auth failure, which it shares rather than prompt again.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(runtime-env): a late reply from a replaced pairing never overwrites the re-paired device identity (#25490)

* fix(runtime-env): a reply from a replaced pairing never overwrites the re-paired device identity

* fix(types): narrow identity fields in markEnvironmentUsed

* fix(ssh): managed tunnel proves its server by runtime id; SSH access linking keeps the strict device check

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): retained scrollback survives a closed tab, local acknowledgements stop blocking, close-intent retirement retries, mobile selections merge per workspace (#25508)

- A transfer reads a retained snapshot straight from storage once its tab closes, and releasing the retention deletes a ref no session names; the frozen-source check leaves the snapshot list to the journaled manifest.
- Only acknowledgements on panes the source host owns count toward the ui-routing blocker.
- Retiring close intents treats an absent source with an identical destination entry as already done.
- Importing a device's mobile selections keeps its selections for other workspaces.

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(ssh): an expired lease an older relay still lists is never retired (#25449)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): keep each host's state its own, count only saved commits, and never wedge connect on a partial session (#25516)

* fix(migration): a host-qualified owner key belongs only to its own host

Owner matching stripped a key's host qualifier before matching its repo id, so converting host A claimed, moved and on retirement removed host B's session state when the two hosts share a repo id, and destination-qualified focus written by retarget read as leftover source state, failing retirement with orcad_migration_source_ui_routing_reappeared. A qualified key now matches only its own host, and an unqualified key in another host's session partition belongs to that host.

* fix(migration): a partially written session partition never wedges connect, and a marker two partitions agree on stops blocking the move

Real-host BUG-14: the renderer's per-host snapshot leaves out maps a host has no rows in, and main stored host partitions exactly as sent, so a runtime partition lacked tabsByWorktree and the dormant-state collector threw on every connect. Main now fills the required maps on every host-partition write, the migration collectors tolerate a partition persisted without them, and an unreadable session blocks the move instead of failing connect.

The same profile's workspace-session blocker was a false positive: the local and host partitions both carried defaultTerminalTabsApplied for the moved worktree with the same value, and the fragment merge refused any shared worktree key. It now refuses only when the partitions disagree.

* fix(migration): only a flushed commit acknowledgement moves a migration to committed

A committed state read may come from a receipt the server holds in memory but failed to flush. A retry took that read as proof, journaled destination-committed and went on to retire the source, so a later server restart could lose the catalog on both sides. A committed read in stage and in abort is now confirmed through the idempotent commit(), which flushes before it answers; a failure leaves the journal and the fence where they were.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): relay shells no lease here knows count as live, Move stops them, and Windows asks every relay pipe (#25518)

* fix(ssh): a relay shell no lease here knows counts as live, and terminate stops it

A CLI-created terminal has no lease, so with an attached lease the gate answered from leases alone,
a live decision was never re-counted once the relay could answer, and terminate stopped only the
shells it held leases or panes for. The relay's own listing is the authority on what runs.

* fix(ssh): a Windows connect asks every relay version's pipe for its PTYs before converting

Windows pipes cannot be listed, so the connect-time census answered 'unenumerable' and a shell no
lease here knew let the host convert under it. Each version directory's pipe for this target is
derived from its path, so the census probes them all, current included, and asks a live one
through its own bridge; a live pipe it cannot ask is unverifiable.

* fix(ssh): the terminal gate counts what earlier relays still run, leased or not

After an app update a shell a respawn superseded on its tab keeps running on the previous relay
with no live lease here, so a decision counted only the leased shells and Move could not see it.

* fix(ssh): a Windows relay folder with a live pipe it cannot ask stays unverifiable

Each pipe is probed and asked on its own, so one that answered with no PTYs can no longer stand in
for a live sibling the census could not reach.

* fix(ssh): terminate also stops shells only an earlier relay lists

provider.shutdown routes a held id to the older relay that runs it, so the terminate set now takes
listPreviousRelayPtyIds too; one it cannot reach is reported unverifiable as before.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a refused Move to managed server reconnects the host on its relay (#25543)

Stopping the terminals closes the relay session, so a move the census refused (another desktop's
terminals, or an older relay it can't rule out) left the host and its workspaces disconnected
until a manual Connect. The refusal now reconnects the host; the connect-time decision reads the
same census and keeps the relay. A reconnect that converts after all reports the move; a
reconnect that fails still reports the refusal.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(relay): an agent exec ends on its child's exit, not on pipes a background process still holds (#25544)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): stop the automation scheduler first when a managed stop is dispatched (#25548)

A dispatch could otherwise race the daemon retirement census or write a run record that a
rollback restore then silently discards.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): count a degraded daemon's in-process terminals in the terminal census (#25545)

In degraded mode fresh terminals run on the local fallback inside orcad, but the census read
only daemon adapters, so an update or stop saw 0 live sessions and killed running agents.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a retained source whose drafts or settings an older build changed is never hidden as unchanged or retired (#25549)

* fix(orcad): a retained source whose drafts or settings an older build changed is never hidden as unchanged or retired

The retained-source fingerprint covered only repo, folder and group identity, so an unsaved draft
edited on an older build read unchanged: the host kept serving the server's older draft and
retirement deleted the newer one. Retention now also records a versioned fingerprint of the source's
drafts and user-authored names and settings; a mismatch marks the host changed, and a journal
without one is never retired automatically.

* fix(orcad): a retained source's automations are part of its state fingerprint

An older build can edit an automation the source keeps; retirement would delete that edit as if
the server held it.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a rollback restores the older snapshot only behind orcad's terminal barrier (#25551)

The rollback's census is taken while orcad still admits work, so a terminal or automation that
starts before the stop had its state wiped while its PTY survived. The incumbent is now stopped
through its managed stop with idle-daemon retirement: orcad closes terminal admission on every
daemon generation, counts live sessions under that fence, and retires the daemon only when none
exist. Only 'retired' lets the older snapshot replace state; live, unverifiable or a missing
answer refuses and relaunches the incumbent on its untouched state. A build without managed stop
is refused before anything changes.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): failed managed setup leaves 'connecting', edited managed host redials, idled-out server starts before its census (#25474)

* fix(ssh): a failed managed setup leaves 'connecting', an edited managed host redials, an idled-out server starts before its census

- doConnect publishes the error and clears the 'setting up' status when the managed-server
  decision throws for a still-current attempt; a cancelled one still reports cancellation.
- Editing a managed host's connection fields closes its tunnel and disconnects its transport,
  serialized with the target's lifecycle, so the next use dials the edited target.
- The terminal census starts a server that idled out behind a forward still up, so Stop, Update,
  Rollback and status no longer refuse with 'census unavailable' on every retry.

* fix(ssh): a fenced failed setup publishes its cause, never-launched slots are collectable, a reused PID is not orcad

- doConnect publishes the relay decision's setup failure (and clears 'setting up') when a failed
  managed setup kept the host fenced, instead of throwing a bare 'serves a managed server'.
- The liveness probe answers NEVER_LAUNCHED for a slot with no process record and no readiness
  file; GC removes such a slot, and every other reader still reads it as UNKNOWN.
- On POSIX a PID whose command line does not run the slot's orcad.js reads DEAD, so a stopped
  orcad behind a reused PID is woken instead of reported serving or unverifiable.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a legacy relay route a new pane started serving stays open when a pending stop settles (#25542)

A served PTY's exit that lands before its stop's reply defers the route's hang-up until the stop
settles. A second pane the same older relay holds could start serving through the route in that
window, and the deferred hang-up then closed it anyway, sending that pane's input and stops to the
current relay. Serving a pane now cancels the deferred hang-up, and a settling request hangs up only
a route that serves nothing.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): the journal holds a migration's scrollback until commit or abort, across failed uploads and restarts (#25550)

Retention was scoped to one transfer call, and its release in finally deleted a closed tab's
snapshot after an interrupted upload, so every retry failed with source_snapshot_changed.
Inline buffers had no file for the retained read at all. Retention now follows the cutover
journal: held from the journaled export through staging, rebuilt at startup, released on
commit or a removed journal. Inline bytes are written to their ref while held.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

* fix(migration): a repo id two hosts share never lets legacy keys cross hosts, and a dangling identity alias stops blocking the move (#25558)

- Repo ids are not unique across hosts: the same id may be registered on two SSH hosts. Stores that are not session partitions (worktree metadata, automations, lineage, client state, sparse presets, retired names) can hold legacy keys with no host qualifier, so an id both hosts register said nothing about whose a key was. The scope now records such shared ids; an unqualified key or bare repo id for one only matches with its row's own host evidence (worktree metadata's hostId, an automation's ssh target), so another host's rows are never moved, counted or retired. An automation's target generation now matches only alongside its target id.
- An identity alias whose identities hold no metadata (worktreeMetaByIdentity lost them, as on the B4 profile) is nothing to move rather than a worktree-metadata blocker; retirement drops it.

Co-authored-by: m4air <m4air@Mac.localdomain>

* feat(cli): orca environment recover --accept-changed-state --yes restores over changed state (#25597)

Recover refused when a rejected build changed profile state, and its refusal told the user to run
Recover, which the CLI could not do. --accept-changed-state (confirmed with --yes) maps to the same
acceptChangedState the Managed servers settings pass, and the refusal now names the flags.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): refuse an update that would end a degraded host's in-process terminals (#25598)

The census now reports inProcessSessions separately. Those terminals run inside orcad and
end with any restart, so planOrcadUpdate defers with a non-forceable
orcad_update_ends_in_process_terminals instead of claiming they survive on the daemon.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): a delta move protects what it imported from rolling back across it (#25599)

A delta move never advanced the server's migration mark, so rolling back an update taken before the delta was admitted and dropped the delta's projects. The delta now records the mark before any commit can land, resumed commits included; the mark keeps the latest migration and never moves back; and the rollback gate also counts every journal into the server, so deltas finished before this change stay protected.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a missing relay inventory never proves terminals exited, and the Windows census covers every desktop's relays (#25611)

- The migration terminal gate returned `exited` whenever this desktop held no unresolved lease, even
  when the current relay or the earlier relays could not be asked. A failed or incomplete inventory
  is now `unverifiable` regardless of local leases. With no relay session at all the gate asks for a
  host census (`needsHostCensus`) instead of reading the silence as exit; the connect, conversion and
  delta move pass that census in, and the connect hands its own census result to the conversion it
  starts. A census that cannot list endpoints (`unenumerable`) is unverifiable too.
- The Windows connect-time census derived pipe names from this desktop's target id only, so another
  desktop's relay on the same account was never probed. It now lists every `orca-relay-*` pipe on
  the machine and maps each to the relay instance that owns it through that instance's credential
  file or active-pipe marker, asking each with its own credential. A pipe no version directory
  accounts for is unverifiable unless the host proves it another account's (or gone), and an
  inventory that could not be read is unverifiable.

Co-authored-by: m4air <m4air@Mac.localdomain>

* refactor: drop the unshipped pty.resumeClient relay method and unused SSH provider unregister guards (#25595)

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a connect still deciding its server holds the raw 'connected', closes a transport its cancelled decision opened, and an edit keeps a relay host's session (#25641)

- handleSshConnectionStateChange holds a raw 'connected' while a connect is in flight even before
  any relay session exists (published as 'connecting'), so the census, deploy or conversion that
  dials the pool no longer reports the host up with no providers.
- priorConnection is captured before the server decision; a connect cancelled after the decision
  closes a transport the decision opened, unless a newer connect is using it.
- Editing a fenced host an older build changed (it runs on the relay directly) no longer
  disconnects its transport; only a host reached through its managed server redials.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a retained source is unchanged only against a pre-commit baseline of everything a user wrote (#25602)

The state fingerprint read drafts, automations and workspace metadata from what a move could
carry, so a session a move refuses hid an older build's draft edit, and retention hashed the source
after the commit, so a crash before retention blessed whatever an older build changed in between.
The baseline is now written with the fence, before any commit is possible, from the source read
directly; a session that cannot be read, or a journal without that baseline, is unverified.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a delta move refuses a source that changed while it checked terminals (#25691)

The plan and manifest are taken before the terminal check and session release are awaited, but the
journal took its baseline after them, so a draft typed in between became the baseline while the
server received the older one, and retirement deleted the newer draft. The baseline now comes from
the plan's own snapshot, and a source that changed since it refuses the move.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): a save landing mid-retirement no longer defers retirement (#25692)

Retirement removed the source rows, then awaited the profile flush, then checked nothing came back. A session save that landed during that flush re-added a source-owned row, the check failed, and the journal stayed committed until a later connect. Retirement is idempotent, so it now runs one more pass before deferring; a row back after that is reported with the partition and owner key it reappeared under.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(automations): a headless run never reads completed when its agent never ran (#25700)

* fix(automations): a headless run never reads completed when its agent never ran

orcad (and Electron serve) finished a dispatched run on a satisfied
tui-idle wait, and a ready shell prompt satisfies it: a run whose agent is
not installed read 'completed' within seconds. Like the desktop runner,
completion now needs the agent's own status for the run's pane after
dispatch; without it the run fails after the agent-start window with the
reason, instead of claiming completion.

* fix(automations): keep idle-means-done for agents without status; fail only a refused command

Not every automation agent reports status on orcad (no hooks on the host, no
recognised title), so requiring it would fail their runs. A run completes on
the agent's own status, fails when the shell refused the agent's command
(bash, zsh, dash, fish, PowerShell, cmd), and otherwise keeps the old
idle-means-done rule after the agent-start window.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): the retained-source fingerprint sees all worktree metadata retirement owns (#25694)

* fix(migration): the retained-source fingerprint sees all worktree metadata retirement owns

The state view read only legacy worktreeMeta keys and attributed them without meta.hostId, so
identity-backed metadata and unqualified rows a shared repository id leaves to hostId were missing
from the fingerprint: an older build's edit read as unchanged and retirement deleted it. The view
now uses the same attribution as export and retirement, and metadata that claims the source host
but cannot be attributed leaves the source unverified. The fingerprint version moves to v2.

* fix(migration): the retained-source fingerprint skips automations on a repo id another host shares

The state view matched automations by scope.repoIds, which ignores sharedRepoIds, so host A's
fingerprint included host B's automation on a shared repository id; editing it marked A changed and
routed it back to the relay. The view now uses the move's automationTouchesScope.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a failed readiness read no longer kills a healthy candidate; log unsettled activations (BUG-17a) (#25701)

* fix(orcad): a failed readiness read no longer fails a candidate's launch, and log every unsettled activation

BUG-17: the candidate went ready and the client SIGTERMed it ~1 s later through its reject path,
yet that readiness passes the gate, so the launch itself failed: one readiness-wait exec that
errored failed the launch outright. Retry such reads until the readiness deadline; an
unconfirmed termination still fails at once. The update's outcome never reached the app log,
so every update or rollback that does not go through now logs its code and reason.

* fix(orcad): the host-side readiness wait ends on a wall-clock deadline

On a loaded host each poll's reads outlasted its sleep, so the step-counted loop ran past
the client's 30 s exec timeout. That timeout failed the launch, the reject path SIGTERMed a
candidate still starting, and orcad, which defers a stop until startup completes, published
readiness and then exited (BUG-17).

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(migration): stop a converted host's stale tabs landing in local, and retry retirement only on an exact replay (#25712)

* fix(migration): retirement re-removes only an exact replay of moved session state

#25692's second pass re-ran retirement on any row that reappeared during the flush, which could delete a tab or draft written after the move. Retirement now records the session rows it removes before it runs; a row that reappears is removed again only when it is byte-identical to one of those (in any partition). A new or changed row defers retirement and stays.

* fix(ssh): a converted host's leftover session rows stay in its own partition, never local

After conversion the renderer drops the SSH host's projects and worktrees but keeps their session rows. With no catalog owner left, the next save routed those rows to the local partition, where retirement read them as moved source state reappearing and deferred. Converting now pins each dropped worktree's session key to the host's partition; main's fence guard keeps that partition frozen, so the stale rows are never written.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(test): restore the codexProviderHandle import main's #25078 dropped again

#25722 restored it, then #25078 removed it, so pnpm tc fails on main's tip.

* chore(sync): keep main's own cloud and mobile files byte-identical to main

Earlier syncs added lint-only brace and template fixes to these main-owned files; reverting
them keeps #24863's diff against main free of files Phase 3 does not own.

* fix(terminal): a remote pane's reattach error no longer shows this client's OS and shell (#25693)

* fix(terminal): a remote pane's reattach error no longer shows this client's OS and shell

The error toast appended the client's environment for every non-SSH error. It now shows it only
when the pane's known execution host is this client; an SSH, managed or not-yet-known host omits it.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(terminal): type the pane-host fixture as runtime owner state

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

* fix(orcad): a retained activation fence is ownerless, so recovery can always take it over (BUG-17) (#25698)

* fix(orcad): a retained activation fence is ownerless, so recovery can always take it over

BUG-17: a recovery that took a stale fence over, failed and retained it left a fresh lock, so
every later recovery read it as still fresh and the host could never be recovered. A run that
keeps the fence once it is done now backdates the lock; one whose remote command may still be
running keeps it fresh.

Also run the in-process terminal deferral before the forced protocol check, so a degraded host
whose daemon is empty names its in-process terminals instead of an unreported protocol.

* test(orcad): the CLI's accepting recover takes over a fence a refused recover just retained

The fake host now answers a stale-only takeover busy while a recovery's own takeover is
fresh, which reproduces BUG-17's 'still fresh' loop without the ownerless mark.

* test(orcad): keep the fake host's fence acquisition void where callers expect it

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a cancelled connect closes only the transport its own decision opened and nothing newer adopted (#25696)

* fix(ssh): a cancelled connect closes only the transport its own decision opened and nothing newer adopted

#25641's cleanup took any transport that differed from the pre-decision one as the cancelled
decision's, guarded only by connectInFlight. A replacement connect that completed (and left
connectInFlight) then had its live transport disconnected by the stale attempt.

The pool now attributes a transport it opens inside a connect's server decision to that attempt
(AsyncLocalStorage), and the latest user adopts it: a connect that connects or publishes a managed
route, or a managed tunnel that records a forward. A cancelled attempt closes the transport only
when it opened it, nothing newer adopted it, and no current replacement is in flight.

* fix(ssh): a still-current connect whose server decision fails closes the transport that decision dialed

The decision's own failure (a throw, or a fenced relay refusal) published 'error' but left the
transport it dialed open, so getPublicSshState read 'connected' and a later auto-reconnect
broadcast a plain 'connected' with no relay. Both branches now close exactly the decision-owned
transport through abandonDecisionTransport, which treats the attempt's own in-flight entry as
no newer owner while that attempt is still current.

* test(ssh): name the stand-in transport type

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): Windows readiness identifies itself by one PID when the process-table snapshot times out (#25723)

* fix(orcad): Windows readiness identifies itself by one PID when the process-table snapshot times out

A loaded Windows runner timed the whole-table snapshot out during the bundled runtime's
readiness preflight, so the candidate failed to start and activation rejected it.

* fix(orcad): fall back to the one-PID query only for a slow process table, never an unreadable one

An unreadable table (EDR-hooked snapshot, restricted token) must still fail qualification.
The table now rejects slowness with a typed WindowsProcessTableTimeoutError, and only that
falls back. Review by win-serve.

* build(cli): list the process-table timeout error in the CLI project's file list

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a connected conversion proves the host idle with the account-wide census, not this target's lists (#25697)

* fix(ssh): a connected conversion proves the host idle with the account-wide census, not this target's lists

The migration terminal gate asked the host-wide census only when no relay session existed. With a
session, it trusted this target's relay listing and its earlier-relay census, both of which name
only this target's instances, and returned `exited` when they were empty, so another desktop's live
shell on the same account, under a different target id, let the host convert under it.

`exited` now always needs a complete host-wide census: this target's lists can prove `live`, but
empty lists only pass the question to the census, and a gate given none answers `unverifiable`
with `needsHostCensus`. The connect-time refinement passes the census too, so a connect retires its
leases only when no relay on the account holds work. The Windows host lane now runs a second
desktop's relay with a live shell and expects the connected gate to read the host live.

* test(ssh): a connected relay's empty lists still ask the account-wide census

* refactor(ssh): drop the gate's unread needsHostCensus flag; its unverifiable reason says why

* test(ssh): the delta snapshot fixtures give their account-wide census

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a reconnected transport starts managed orcad itself instead of inheriting a dropped start (#25689)

* fix(ssh): a reconnected transport runs its own orcad start instead of inheriting a dropped one

The serving check deduplicated in-flight starts by environment only. When a
connect dropped mid-start (as when the launch-time auto-connect is replaced
by a reconnect), the caller on the new transport joined the start bound to
the dead one, got its failure, and reported the host managed with no server
running. In-flight checks now join only on the same connection, connect
generation and port, and a wake's own fence token is cleared only by that
wake.

* fix(ssh): a reconnected wake releases the fence its dropped wake held at any point

The flake's real verdict was 'fenced': the dropped launch-time wake held the
activation fence, and the reconnected wake could not prove it its own. A
wake now claims its token before its first remote step, an absent owner
record under a held token is still its own, and a reconnected wake waits for
this client's dropped wake to settle before reading the fence.

* fix(ssh): release only a fence carrying this process's own wake token

A fence with no owner file could be another client's fresh one. Releasing it
now requires the owner token this process wrote; the token is claimed before
the write so a drop after it still proves ownership.

* fix(ssh): type the wake's fenced fallback

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(ssh): stop the exec-stdin test double from failing on EPIPE (#25739)

The truncation test's fake exec channel forwarded the local shell's
EPIPE (or 'Cannot call end after a stream was destroyed') as a channel
error. Whether that error or the shell's exit code won depended on
scheduling, so the test failed under full-suite load. ssh2 silently
drops writes once the remote stops reading; the double now does the
same, and a 1 MB payload makes the early-stop path deterministic.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): Move lets the reconnect's decision run its census, and a census outside a connect holds 'connected' and closes its own transport (#25735)

- Move to managed server no longer runs a separate host census after tearing the relay down,
  which dialed the pool with no connect in flight and broadcast a raw 'connected' with no
  session or providers. It reconnects, and reads the decision the reconnect's census recorded.
  A relay a failed stop left up is detached (leases kept), not disposed, before the reconnect.
- The CLI and delta-move census (censusHostRelayTerminalsFor) runs outside a connect under its
  own owner: the raw 'connected' it causes is held, and a transport it opened that nothing
  adopted is closed afterwards. Reusing a pooled transport inside a scope now adopts it.
- Drop the now-unused publishRelayTerminalsStatus.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(test): drop the restored codexProviderHandle import now that main restored it

* refactor(migration): keep a converted host's source rows instead of retiring them automatically (#25768)

Automatic source retirement leaves Phase 3: nothing deletes a converted host's retained rows on connect, delta move, keep-server's-version or restart. They stay hidden and are removed only by stopping the server, removing the host or uninstalling. Change detection goes back to the catalog-identity fingerprint, so an older build's edits inside an already-moved project stay preserved in the retained rows without marking the host changed. The copy-only helpers the delta view uses are renamed to subtract, and the converted-host session pin now also overrides a boot primary of local.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(automations): a headless run is watched past tui-idle timeouts by the one run observer (#25733)

* fix(automations): a headless run is watched past tui-idle timeouts by the one run observer

The headless dispatcher awaited a single tui-idle wait, which rejects after
its 5-minute default, so a healthy agent working longer was published as
dispatch_failed and never observed again. The dispatcher now hands the run
to its completion watcher, whose runtime observer already re-arms wait
timeouts, honours cancellation and bounds total observation; the agent
status and missing-command checks fold into that observer, and the separate
completion loop is gone.

* fix(automations): an already-idle pane completes when the start window passes

Real-host: a stub that exited before the window left an idle shell with no
agent status, and the observer re-armed a tui-idle wait that never resolves
for an already-idle shell, so it timed out instead of completing. The
observer now keeps judging the pane while its output is unchanged, and only
waits again once the pane changes.

* fix(automations): resolve a watched headless run by its launch handle first

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(runtime-env): a re-paired managed server's subscribers recover without a reload (#25752)

* fix(runtime-env): a re-paired managed server's subscribers recover without a reload

The renderer kept the pairing revision it last read, so after an on-connect update re-paired a
managed server every subscribe and request was refused as 'pairing changed' until a reload. The
first refusal now re-reads the environment catalog, so revision-keyed subscriptions resubscribe
and requests carry the new pairing. A managed server re-pairing for the same host registration is
the same peer, so its workspaces and tabs are no longer purged as a replaced environment.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(runtime-env): a re-paired managed server is the same machine only when its host proves the same identity

Same SSH target registration is not proof: a reinstalled host or a target now pointing elsewhere
keeps it. The runtime id the pairing handshake verifies must be known and unchanged; otherwise the
re-pair retires the environment as before. A proven runtime id change also counts as replaced.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(runtime-env): decide a managed re-pair's same machine by the host's proven key, not its runtime id

The runtime id is minted per process start, so every orcad restart would read as a new host.
The host's E2EE public key persists in its own profile across updates and its pairing handshake
proves it; main now lists a digest of it, and the renderer keeps a re-paired managed server only
when that digest is known and unchanged under the same SSH target registration.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(runtime-env): defer a managed re-pair's same-machine decision until the host key is known

A re-read that lands before the new pairing's host key is listed no longer purges: the decision
waits for a catalog that carries the key and retires only if it differs. Adds the update-flow
store test: same registration and key keeps workspaces and tabs, including a re-read that runs
before the key is known.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(runtime-env): watch for a pairing refusal on a side branch so requests settle on the same tick

Chaining .catch onto every subscribe and request delayed each success by a microtask, which let a
StrictMode cleanup run before a client-event subscription resolved, so its unsubscribe landed late.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

* refactor: consolidate pane-ownership, migration-catalog, activation-launch and parser helpers (#25738)

* refactor(orcad): one launch-and-judge helper for activation and rollback

* refactor(orcad-migration): one copy each of the destination projections, selectNewRows, assertSameValue, compareKeys and slotLiveness

* refactor(orcad-migration): one string-list validator and one uniqueness check, error codes passed in

* refactor: one shared pane-ownership and terminal-layout module for migration, profile transfer and split layout

* fix(orcad-migration): row-identity helpers in a leaf module (no import cycle); key order in its own module

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): reopen a managed tunnel to a host another desktop restarted (#25800)

The tunnel's identity check pinned the saved runtime id, which orcad mints per process. A host
updated or woken by another desktop, or restarted while this one was away, failed every reconnect
with orcad_identity_mismatch, and nothing could refresh the id because that needs the tunnel. The
E2EE handshake with the pinned host key and our accepted token now prove the server; the first
authenticated status reply records the new id. A different host is still refused.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

* fix(orcad): create the readiness file owner-only so its pairing token is not world-readable (#25809)

orcadLaunchCommand truncated .orcad-readiness before setting umask 077, so under a
login umask of 022 the file that receives the pairing offer (with a runtime-scope
device token) came out 0644. umask 077 now runs first, the readiness file is
chmod 600 after the truncate (a redirect keeps an earlier build's 0644), the pid
and log files are tightened too, and the slot dir and ~/.orca-remote are chmod
700 so files earlier builds left readable are no longer reachable. The state
snapshot capture also sets its umask before creating the snapshot directory.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(terminal): explain a pane whose saved session another host connection owns (#25814)

terminal_pane_owner_host_mismatch reached the user raw, with an issue link. It now reads as a
plain explanation with the open-a-new-terminal action, like the reattach failure.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

* test(topology): allow phase3's headless editor-tab retirement in main's boundary ratchet (#25823)

Main's #25329 added the ratchet; phase3's mobile-session-editor-projection.ts writes the host's
own session through setWorkspaceSessionForWorktree, the same way the listed headless
mobile-session tab writers do.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(automations): a headless host closes finished run terminals, keeping the newest few (#25831)

* fix(automations): a headless host closes finished run terminals, keeping the newest few

The desktop closes a run's terminal when the run completes; orcad had no
renderer to do it, so hourly automations left a shell and PTY per run open
forever (28 after ~6h on a real host). The headless service now closes a
finished run's terminal after a 10-minute grace, keeps the newest three per
automation viewable, and never touches a run that has not finished.

* fix(automations): never close a run terminal a client typed into or is viewing

Mirrors the desktop's take-over rule on headless hosts: a finished run's
terminal stays open when any client drove input to it since spawn, is
attached to or viewing it, or when this process cannot tell (it adopted
the PTY rather than spawned it).

* test(runtime): register a viewer through the public subscribe API

* fix(automations): close only completed runs' own panes

A failed run can still hold a live agent (blocked on a prompt, past the
watch window, or after an observer error), so like the desktop only a
completed run's terminal is closed. And only the run's own pane closes, so
a pane a user split into the same tab survives.

* fix(automations): close a run pane only while it still holds the run's PTY

The use check read run.terminalPtyId, but the close hit whatever PTY now
occupies the run's pane. Restart-exited-pane and the Codex account-switch
restart put a new PTY there, so a terminal a user was using could be killed.
The close now resolves the pane's current PTY and closes only when it is the
run's own; otherwise it closes nothing and only clears the run's terminal.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a snapshot restore past the exec timeout keeps its fence instead of racing a second restore (#25811)

* fix(orcad): a snapshot restore past the exec timeout keeps its fence instead of racing a second restore

A capture, restore or clear ran under the generic 30s exec timeout. On ssh2 the timeout closes the
channel and reads as a confirmed failure, but sshd leaves a pty-less command running, so rollback
ran its rescue restore and recover orphaned the fence for a second one, both in the same stage.

- State mutations run through execOrcadStateMutation: no abort, a client wait past the host's
  deadline, and any closed channel or busy/deadline answer is unconfirmed, so the fence stays fresh.
- POSIX hosts wrap each one in `timeout -s KILL` (where present) and a pid-checked lock dir under
  ~/.orca-remote; the Windows host script takes the same lock.

* fix(orcad): a running state mutation keeps the activation fence fresh

The fence goes stale by its lock dir's mtime after 20 minutes, and a capture, restore or clear can
now run up to 15 under it, so a rollback's rescue capture plus restore could outlast the window and
let a recovery steal the fence from a live run. While a mutation runs, the host now touches the
fence every 60s (POSIX: a background beat that stops with its shell; Windows: an interval in the
host script, whose mutations are now async so the timer runs). A dead process stops refreshing, so
stale takeover still recovers it.

* fix(orcad): the state-mutation fence heartbeat never refreshes a wake's fence

A wake writes .orca-wake-owner into the fence dir and lets its fence age toward takeover; the
heartbeat now skips a fence that holds that token, and only ever changes the dir's mtime.

* fix(orcad): a state mutation releases its host lock before answering, and names its holder by pid and start time

On Windows answer() exits in the stdout write callback, so an op that answered before its first
await (MISSING, EMPTY, FAILED) exited before the wrapper's finally and leaked the lock; a reused
pid then read as alive and every later capture, restore and clear answered busy. Ops now return
their token and the wrapper answers after releasing the lock. The holder is pid plus creation time
(the slot's process-tree addon); one that cannot be identified is stale once its lock misses five
heartbeats. POSIX gets the same heartbeat-age check for a reused pid.

* fix(orcad): a state mutation's host lock is owned by its whole process group

The lock named only the shell's pid, so a shell killed while its rm or tar ran let the next
mutation take the lock and race that child. Each mutation now runs in its own process group
(setsid, or perl setpgrp on macOS), with timeout inside it so a deadline KILL reaches the children
too. The lock records the group, and is taken over only once no member is alive; a host that can
start no group records none, and its lock is never taken over. Windows ops run in-process, with no
children to outlive the holder.

* fix(orcad): record a state mutation's process group without ps -p, and never hold a groupless lock forever

BusyBox ps has no -p, so Alpine hosts recorded no group and their lock read busy forever after a
timeout kill, reboot or OOM. The group now comes from /proc/<pid>/stat (read after the comm field's
last paren), with ps -o pgid= -p as the fallback. A lock that still names no group is taken over
once its pid is dead and its heartbeat has missed three beats.

* fix(orcad): only proof of exit frees a state-mutation lock

A Windows holder whose creation time could not be read was taken over after five quiet minutes
though its pid was alive, so a suspended clear could resume and delete freshly restored profiles.
Both platforms now free the lock only on proof of exit: a dead pid, a different creation time, or
(POSIX) a group with no live member. A live holder of unknown identity stays busy until it exits.
The owner record is written exclusively, so a run that resumes after a takeover backs off.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): never offer Move for terminals another Orca desktop or session runs (#25815)

On a host where another desktop held a live relay shell, this desktop read relay_terminals_live
with offerMove, and its copy ("Its N open terminals will restart") implied they were its own. The
census already attributes them: terminals counted only by the host-wide census, with no lease or
listing of this target naming one, run under another target or session. That verdict now carries
elsewhere / terminalsElsewhere; no move is offered (no toast, no status-line action) and the status
line says the terminals belong to another Orca desktop or session.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(automations): an update releases finished, unused automation shells before counting terminals (#25844)

Hosts with schedules kept completed run shells (the newest three, and any not
yet past their grace), which counted as running terminals and deferred every
on-connect update with orcad_update_terminals_running. The update and
rollback census now ask the server to close completed automation run
terminals no client used, with no grace or keep rule, and count after the
daemon drops them. Used, unknown, failed and running ones still count.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): every activation fence holder carries a generation token its steps and release must match (#25834)

* fix(orcad): clear a bare stale activation fence instead of asking for Recover, and report a restarting update from status

BUG-21: a wake cut short leaves a stale fence with no journal. Every update then answered
'Recover it first' while Recover answered 'none'. The fence-hold check now takes such a fence
over and drops it, and the update retries once; Recover is asked for only over a journal.
The CLI also treats a connection closed by the server's own restart during update or rollback
as expected and reports what status shows once the runtime answers.

* fix(cli): type the reconnect status response explicitly

* fix(ssh): a wake's fence carries its owner token from the moment the lock exists

The idle-exit e2e still read 'fenced' on reconnect: the launch-time wake's
lock landed on the host but its connection dropped before the client saw OK,
so the wake body never ran and never wrote its owner token, leaving a fence
nothing could prove. The token is now claimed before the lock and written by
the same command that creates it, and a wake registers itself before any
remote step so a reconnected wake waits for it instead of racing it.

* fix(orcad): every activation fence holder carries a generation token its steps and release must still match

Astra pass 8: a holder suspended past the stale window resumed, kept acting, and its
unconditional release deleted the successor's fence and recovery journal mid-update. Every
holder (activation, rollback, stop, recover, wake) now writes a token into the lock it creates or
takes over. Each remote step it issues checks that token on the host, in the same command on
POSIX and inside the host script for Windows host ops; release is conditional on the token and
moves the lock aside instead of removing the root. A superseded holder aborts with
OrcadFenceLostError and its release is a no-op.

* fix(orcad): state mutations check the fence token before their lock, and refresh only a fence they still own

On POSIX the fence guard runs outermost in serializedStateMutationCommand, before the mutation
lock and the work, and the heartbeat touches the fence only while the token is still this run's.
The Windows host script records the --fence token and refreshFence compares it. A fence-lost
answer from a state mutation is a refusal, never a FAILED fallback. One owner-file constant
replaces the wake-owner copies.

* refactor(orcad): a state mutation's heartbeat touches the fence directory it checked ownership of (review)

* fix(orcad): a release moves the journal and lock aside and keeps only its own generation's

Astra pass 9: a release that passed its token check and stalled before deleting could, once a
takeover and a successor came and went, delete the successor's journal and lock. The journal is
now stamped with the writing run's fence token (a recovery takeover re-stamps the journal it
adopts), and the release renames the journal and the lock aside, deletes each only if it carries
this run's token, and otherwise moves it straight back.

* test(orcad): a successor restore stays busy beside a paused clear on the Windows host script

* fix(orcad): classify a lost fence from the step's exit and stdout, never the error message

The real exec error quotes the command, and every fenced command carries the
guard's marker text, so any failed or timed-out fenced step read as a lost
fence and dropped its unconfirmed flag. execCommand now attaches exitCode and
stdout to its exit error; execOrcadRemote rethrows unconfirmed terminations
before any reclassification.

* fix(orcad): a wake keeps its fence token until the fence is released

A disconnect fails a wake's next step without the unconfirmed flag, and the
fence release then fails over the dead connection. Forgetting the token on
that error left a fence the reconnected wake could not prove its own, so it
reported the host as held by an update.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(recovery): keep Phase 3's recovery-lifetime test on main's legacy-worker ports

Main added a required hasRequestedReleases port and now skips persist when a pass resolves
nothing, so the test mocks the new port and holds the pass at workspace resolution instead.

* fix(ssh): say "1 terminal" when another Orca desktop runs one on the host (#25853)

The terminalsElsewhere status line had no plural forms, so B9 read "while 1 terminals another
Orca desktop … are running". It gains _one/_other entries like the other terminal-count strings
on that line and in the move offer, which were already pluralized.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(automations): close failed and exited runs' terminals once the shell is proven alone (#25859)

* fix(automations): close failed and exited runs' terminals once the shell is proven alone

Failed (command not found, timeout) and forever-dispatched runs kept one
shell per run, unbounded and counted by the update gate. Their terminals now
close like completed ones (unused, past the grace, outside the newest few)
but only on fresh execution-host proof that the spawned shell is alone at
its prompt; a live or unprovable agent keeps its terminal. A still-dispatched
run closed this way is marked failed. Dead terminals no longer take one of
the newest-three keep slots. The update drain follows the same rules.

* fix(automations): prove a run shell alone from the process table, not the daemon's ownership flag

On a real daemon session the daemon's confirmShellForeground stays false
after a plain 'command not found' and after an agent that exited, because its
ownership flag only turns 'shell' after a full-screen command; failed runs
would never have closed. The proof now also reads the host's process table:
on POSIX the PTY's root shell must own the terminal foreground group with
nothing stopped under it, on Windows the host's job-based child census must
be empty. Anything unobservable still keeps the terminal.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(ssh): extensive orca CLI matrix on Windows hosts (#25114)

* test(ssh): extensive orca CLI matrix on Windows hosts

Adds three dispatch-only app cells to the ssh-windows-hosts lane that drive the
e2e build and the bundled orca CLI against the provisioned Win32-OpenSSH host:
empty-host deploy/terminal/reconnect/orcad-restart/decommission, seeded
relay-era conversion, and an open relay terminal keeping the host on the relay.

* test(ssh): pin the relay-kept cell's runtime; keep cleanup from masking failures

* test(ssh): run decommission before the orcad restart in the managed cell

* test(ssh): decommission through orca environment stop; accept an unverifiable relay close

* test(ssh): require a confirmed relay close; app cells must run last

* test(ssh): log and accept either relay-kept census reason; keep app-cell test results

* test(ssh): relay-kept requires a live census and its status line again

* test(ssh): match the pluralized relay-kept status line

* test(ssh): orcad restart proves a new process, terminal adoption, and a kill-then-connect relaunch

* test(ssh): restart kills only the orcad server, not its terminal daemon; wait for a released profile

* test(ssh): collect orcad.log.1 so a restarted orcad's previous run is kept

* test(ssh): the managed cell proves a workspace listener is detected and attributed

* test(ssh): start the port listener without $, so a PowerShell terminal doesn't expand it

* test(ssh): the port check proves Windows command-line attribution; retry a dropped version read

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* refactor(runtime): move run-terminal client-use and shell-alone checks out of the branch-cleanup runtime (#25876)

* refactor(runtime): move run-terminal client-use and shell-alone checks out of the branch-cleanup runtime

orca-runtime-preserved-branch-cleanup.ts had grown past max-lines (303) with
the headless run-terminal helpers. Their logic now lives in
run-terminal-client-use.ts and the runtime keeps one-line delegators, with
behavior unchanged.

* fix(ci): the runtime Electron ratchet bundles its entry points once, not 2.5k times

check-runtime-electron-ratchet bundled ~2,532 entry points each in full (format cjs, no
splitting), so esbuild held thousands of copies of the runtime graph: about 2.2GB RSS and 11s per
run, twice per test file. It was in flight in every unit shard that died with "The runner has
received a shutdown signal" (#25815 5/5 twice, #25876 2/5 twice). With esm + splitting the shared
modules land in one chunk: same metafile, about 200MB and 2s.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* test(ssh): wait for the busy relay's child before probing it (#25916)

The fake relay's spawn is not visible to pgrep at READY on Linux under Bun, so
the probe could count zero children. The sibling cases already wait.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): a quit that aborts an upload whose read already ended no longer crashes main (BUG-23) (#25922)

Quitting while an on-connect orcad update was uploading its bundle aborted the connection's
teardown signal. sftp-upload's abort handler destroyed the local read stream with the signal's
reason, but once that read had ended, 'finished' had already removed its listeners, so the
stream emitted an unhandled 'error': [main_uncaught_exception] AbortError: This operation was
aborted. Electron's error dialog then blocked the main thread and the app never exited.

The read stream now always has a no-op error listener; the transfer's outcome still comes from
'finished'. Both the bare upload and the connection-level teardown abort are covered.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a launch reads readiness at least once; fake hosts match the capture's tar flag, not any -cf (#25918)

A random fence token contains `-cf` about 1 time in 125, and the fake hosts
read any command containing it as a snapshot capture, so a rollback's restore
answered CAPTURED and the rollback never launched. Separately, a client
descheduled between computing the readiness deadline and checking it skipped
every read and failed a ready launch.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a fence this desktop's exited process left is cleared without the 20-minute wait (#25941)

* fix(orcad): a fence this desktop's exited process left is cleared without the 20-minute wait

The client records the fence tokens its processes hold beside the profile. On
a later launch, a POSIX fence carrying a token from a process that has exited,
quiet for three heartbeats and with no live state mutation, is backdated so
the existing stale rules clear it or hand it to Recover at once. Another
desktop's fence, a live holder's, or one with a mutation still running keeps
the normal stale window.

* fix(orcad): held fence tokens are best effort, pinned to this machine and boot, and pruned after a day

A token-file write that fails no longer breaks a fence operation; an entry
recorded on another machine sharing the profile, or before a reboot, never
proves its holder exited; entries older than 24 hours are dropped. Tests cover
a journal kept for Recover, a successor freshened back after a racing backdate,
a Windows host, and the record across release, supersession, busy and a lost
connection.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): an install lock this desktop's exited process left mid-upload is taken over without the 20-minute wait (#25991)

A quit during the bundle upload leaves the version dir's install lock, not the
activation fence. The lock now carries this desktop's token, recorded in the
held-token store, and is forgotten only once its removal is confirmed. On a
later attempt, before each stale check, a POSIX lock whose token belongs to an
exited process of this machine and boot, quiet for three minutes, is backdated
so the existing stale takeover claims it at once. The fence path now shares
the same helper.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): a desktop that met another desktop's update fence clears its note once the host answers (#25995)

The serving note "holds this host" and a fence-busy update deferral stayed until a reconnect,
minutes after the other desktop's update finished. The connect now rechecks serving and the
update every 45s while the fence holds, and publishes the first answer without it. A recorded
deferral is dropped once the host runs its candidate or a newer release.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

* test(ci): run Phase 3's SQLite-backed tests in the Node runtime project

Main's #25967/#25998 boundary requires every test that opens real SQLite to be listed. This adds
Phase 3's eleven orcad and SSH migration tests, plus main's own agent-launch-instant-tab test
(#25430), which main's tip also leaves unlisted.

* fix(ci): keep Electron probes out of the node-server suites again (#26046)

The runner excluded *.electron.test.ts with a CLI --exclude, but main's switch to Vitest inline
projects (#25967) gave each project its own exclude list, which overrides the CLI one. The
directory selectors then pulled profile-state-writer-stall.electron.test.ts into the glibc-floor
and musl orcad-template jobs, which have no xvfb. Resolve the exact files with vitest list and
drop Electron and cross-runtime ones before running.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): keep the SSH host card quiet while its managed server is healthy (#26072)

The card showed "Runs a managed Orca server" under every healthy host. A managed server is the default, so the status line now appears only for setup progress, updates, the relay, or failures.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): Move to managed server keeps the host's terminal tabs (#26077)

* fix(ssh): Move to managed server keeps the host's terminal tabs

Move stops the relay shells; their exits read as a user exit and closed
the tabs before the conversion copied them to the server. Suppress those
exits for the move, restart stopped shells on the relay when the host
stays, re-home the open workspace onto the server, and report stopped
shells to the runtime so terminal list stops calling them connected.

* fix(ssh): mark Move's relay stops in main's intentional-stop register

The renderer-only exit suppression left main retiring the stopped tab from
the saved SSH session before the conversion copied it, left other viewers
unprotected, and swallowed real exits for the whole request. Register
exactly the shells the move stops, from just before each shutdown, as a
'replaced' stop with their incarnation; main keeps the surface and labels
the exit for every viewer, while a confirmed death stays 'exited'. Move
now returns the shells it stopped, and a host that stays on the relay
restarts only those tabs, discarding any buffered exit first.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(hosts): show an SSH host and its managed Orca server as one host (#26076)

* fix(hosts): show an SSH host and its managed Orca server as one host

Phase 3 registers the Orca server it deploys over SSH as its own runtime environment, so every
host list built from the execution-host registry listed the machine twice under the same name.
The registry now folds the pair into one row named after the SSH host. The row routes to the
server, since a managed host has no relay, unless main reports the host back on its relay; the
other id stays as an alias so selections and renames saved under it still resolve. A retired id
that workspaces still point at keeps its own row, and servers no configured SSH host deployed
(manual pairings, orphans) are untouched.

* fix(hosts): keep both ids of a merged SSH host and dedupe only in pickers

Deleting the merged-away id from the registry broke every consumer that matches hosts by exact
id: Add Project fell back to local after a connect, the composer lost ready projects and drafts
(and could swap in an unrelated local project), and a host scope hid folder-only workspaces.

The registry now keeps both entries and marks the pair (aliasHostIds on the row pickers show,
mergedIntoHostId on the other). Pickers show one row per machine, and a choice of that row
expands to both ids: sidebar host scope, jump palette filter, notification toggles, run-target
and repository host offers. Add Project resolves a saved SSH id to its server row and blocks the
actions while that server comes up instead of choosing local. The composer's resolver now fails
closed when a named draft repo isn't actionable rather than picking another project.

* fix(hosts): widen saved host scopes, both-way palette aliases, guard Add Project host

- A sidebar or agents host scope saved by an older build (or before a route flip) can hold one id
  of a merged SSH host; a background gate widens it to both ids so exact-id filters match either
  owner.
- The palette host filter now resolves a saved id to both owners whichever id it names.
- Add Project's create and clone refuse to run while the chosen host is unresolved, and their
  submit buttons stay disabled, instead of falling through to this computer.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(sync): reconcile main's ratchet bundling and cold-serve hydrate with phase3

The Electron-import ratchet keeps main's single-stdin bundle (cjs); the
auto-merge had also kept phase3's esm splitting, which broke main's
import-graph test. Editor tabs now follow the windowless full-seed rule
from #26022, so a cold serve restart lists persisted editors too.

* fix(ssh): reclaim this desktop's own exited lock on Windows hosts too (#26087)

* fix(ssh): reclaim this desktop's own exited lock on Windows hosts too

The relaunch after a quit mid-update now frees the activation fence and the
version-dir install lock on a Windows SSH host the same way it does on POSIX,
instead of waiting out the 20-minute stale window. The host script ages the
lock only when its token belongs to a desktop process proven exited, it has
been quiet for three heartbeats, and (for the fence) no state mutation is
live, where a mutation holder counts as gone only by pid plus creation time.

* fix(ssh): take an exited holder's lock only through the steal arbitration

Review found the reclaim backdated the lock by path after checking it, so a
live successor that replaced the lock in between could be aged and then
stolen, and an interrupted or failed restore left it aged for good.

The exited-holder check is now read-only. The steal command itself accepts
the proven token and, inside its steal claim and identity recheck, also takes
a lock whose owner file still names that token and that has been quiet for
three heartbeats. Nothing is written to a lock before the steal owns it.
POSIX uses the same path.

* fix(ssh): never take an exited holder's fence while a state mutation can start

Review round 2 found the fence's live-mutation guard ran only in the read-only
proof, so a mutation admitted after the proof, or one whose first heartbeat
landed after the steal sampled the fence's age, kept running under a fence
the steal had replaced.

For the fence, the steal now takes the state-mutation lock inside its claim
(mkdir on POSIX, the exclusive owner.json on Windows) and holds it until the
takeover is done; it refuses when any mutation lock exists. Holding it, it
rereads the owner and only then re-samples the fence identity. A mutation now
rechecks its fence token right after it takes the mutation lock and stops with
the fence-lost marker if it changed. The Windows proof also falls back to the
stale window when its command line would not fit cmd.exe.

* fix(ssh): record the exited-owner steal as a real mutation-lock holder

Review round 3 found the POSIX steal held the state-mutation lock as an empty
directory, which a mutation reclaims after a minute without any liveness
check; a steal stalled that long lost its exclusion and could replace the
fence under a running mutation.

The steal now writes its pid (and group, under the same rule) with the
mutation's own noclobber owner writer, so only proof of its exit frees the
lock, and it removes the lock only while the lock still names it. On
Windows the owner record is moved into place whole, so it never exists
empty, and is removed only while it still names the steal's pid.

* refactor(ssh): keep the relay lock commands off the orcad host-script graph

The mutation-lock owner writers moved into a leaf module, so the relay's
install-lock commands no longer import orcad-state-snapshot and, through it,
the Windows host script, orcad-instance-lock and the daemon process query.
Those modules evaluate imports at load time that existing suites mock
partially. No behavior change.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-10-07 03:15:06 -07:00
Brennan Benson 35e173a5b7 fix(native-chat): alert when a structured chat asks for approval or input mid-turn (#25766)
* fix(native-chat): alert when a structured chat asks for approval or input mid-turn

A structured Claude or Codex chat that stopped mid-turn to ask for approval or a
question raised no OS notification, no phone push and no unread dot: the host's
attention feed only fired when a turn settled. The host now announces each newly
pending prompt once, from the same feed, and owns the phone push for its own
structured sessions; desktops keep presentation only.

- host feed: a `prompt` edge per pending approval/question, opt-in on the
  existing stream; a clean settle that only restates an announced prompt is not
  re-announced to prompt-aware clients; failures always are
- host push: an in-process subscriber pushes the host's paired phones with
  labels from in-memory metadata, and withdraws a prompt's alert once answered
- one attention identity for desktop banner, phone push, dedupe and retirement;
  delivery dedupes on it instead of the per-workspace burst window
- agentSession.acknowledgeAttention routes reading a chat to its owning host,
  which retires what it pushed for that session via its dismissal record

* fix(native-chat): keep prompt alerts under their retirement identity

* Fix structured attention read and delivery lifecycle

* Allow later reads to retry failed attention retirement

* Prevent delayed prompt alerts after accepted reads

* Keep attention integration tests across process boundaries

* Check structured capability before cursorless attention reads

* Keep host retirement fixture in the Node typecheck boundary

* Keep attention test seams in the mock boundary

* Keep host notification formatting usable without Electron

* Declare agents in restored-host attention fixture

* Group session attention capability declarations

* Retire structured alerts on every read and when a remote prompt ends

- Mark read and Mark all read on a chat that was never opened, or is hidden,
  now cover what lit its row: the read boundary is the later of the transcript
  cursor and the newest edge the tab was handed, captured at the click. The
  one boundary closes the banners, retires relayed phone alerts and drives the
  host acknowledgement; an edge after the click stays live.
- An alert from a host older than journal cursors has no position, so a pane
  read retires it again, banner and relayed phone push, as before this change.
- A desktop-relayed prompt alert for a remote chat is withdrawn once that
  host's live status stops reporting attention (answered elsewhere, cancelled,
  host restart); cached status after loss of contact never settles it.
- The attention acknowledgement is agent-session.attention-ack.v1 with a
  required journal cursor and strict params; the cursorless no-op is gone.
- The dismissal store keeps a record whose origin a newer build wrote,
  dropping only that origin.
- Host reconciliation runs on restore and when a prompt leaves the pending
  set, not on every journal commit.
- Remove unused session-wide retirement (retire, retireMatching,
  retireMobileNotificationsMatching) and test-only store lookups.

* Settle relayed prompts only from host-dated status; reads default to view

- A remote chat's status and its prompt edges arrive on separate sockets, so a
  status row queued before the prompt could land after it and withdraw a
  relayed alert that was still pending. Settlement now needs a live,
  non-attention row whose host updatedAt (the journal's latest row time, never
  decreasing) is later than the prompt's host raisedAt. The owed prompt is
  cleared only once the settle call succeeds, so a failed call retries on the
  next qualifying row.
- An acknowledgement without captured reads now reads as a view. Mark read,
  Mark all read, the hover-card jump and both dashboard acks pass 'explicit';
  Activity's automatic per-turn read stays a view.

* Re-judge relayed prompt settlement after each call; dashboard watching is a view

- Settlement is one check of the current mirror, run on each status row, after
  each settle call returns, and when a prompt edge arrives. A row that lands
  while a call is out is no longer dropped, and an edge that arrives after its
  own resolution settles at once. A rejected call is retried only once the
  mirror holds a different row, so a failing call cannot loop.
- The Agent Dashboard's card click acknowledges as explicit; watching the open
  dialog acknowledges as a view, through both the in-window drawer and the
  pop-out relay, and no longer re-acks the state the click just read.

* Keep a reused read owner findable after its holders release it

A chat pane memoizes its read owner. When every holder let go in one commit and
the same owner was picked up again (React StrictMode's effect re-run in
development, or a pane remounted in one commit, as on a tab move), the release
dropped it from the registry and nothing put it back. The attention bridge then
found no owner, so a view read carried no cursor: the unread dot cleared but
the banner and phone alerts stayed. An owner now names its key again whenever
it gains an activation or a subscriber and the key is free, never over a
different live owner.

* Type the ack test's execution host ids

* Keep the turn-completion types in main's turn-completion wire module

Main moved these types into agent-session-turn-completion-wire.ts while this branch moved them into agent-session-attention-wire.ts. They now live at main's path with this branch's additions (journal cursor, prompt attention, prompt arm); the unused completion key is dropped.
2026-10-06 16:25:53 -07:00
Brennan Benson c893048aeb feat(native-chat): show which agent a chat message is from, and open it (#25888)
* feat(native-chat): show which agent a chat message is from

A message another agent sends into a native chat (today the orchestration
mail notice) looked exactly like the person's own: a right-hand bubble and a
queued card identical to theirs. The sender was recorded only on the queue
row, so a message sent to an idle chat, or a card once it was sent, had no
sender at all.

The sender now lives on the message itself (`from` on the message body):
- The send path carries it on both deliveries; an idle chat records it too,
  and it survives the queue, /clear's carry, restart and history.
- No fingerprint covers it: one shared payload projection leaves it out at
  every fingerprint site, so stored digests are unchanged and the provider's
  echo still folds into the same bubble. The provider never receives it.
- A client cannot set it: its send params stay strict.
- One reader normalizes it where it is read (journal load, queued row,
  client projection); a newer message kind keeps the sender an agent's.
- Each sender keeps a name snapshot from names Orca controls: a chat's
  conversation name or agent label, a terminal's own tab title or agent,
  a federated sender's task name. Never an agent-set terminal title.
- The separate queued-row sender column and its plumbing are removed; it
  never shipped.

Desktop shows such a message as a left-aligned "Message from <name>" row;
the name opens the sender: its chat through the existing open-chat flow
(after a host lookup that follows /clear and dispatch addresses), or its
terminal through the terminal-link path, with the existing feedback when
it is gone. A queued card shows a plain "From <name>" line. Mobile shows
the same attribution, not yet clickable.

* fix(native-chat): name and open an agent's message by what Orca shows for it

Review fixes for showing who an agent message is from.

- Names: a sender is named by its dispatch's task (local workers too, not
  only federated ones), else a chat by the label its tab shows, else a
  terminal by its tab's stored title, else its agent. The conversation
  name the snapshot read before has no writer, so every chat read
  "<Agent> Chat".
- One sender builder (agentMessageSender) and a public runtime naming
  object (orchestrationSenderNames) any send site can use; the terminal
  naming moves out of the unrelated runtime layer into its own module.
- Opening a terminal sender always asks the chat's host: a terminal handle
  is issued per run, so the host finds an earlier run's terminal again
  through the pane its mail was sent from. The pane key never leaves the
  host.
- Feedback follows what the host found: a gone chat or a gone pane by the
  kind it lost, "pane no longer available" only when the terminal answer
  proves it, and the existing "couldn't reach" / "needs a newer Orca"
  words otherwise.
- Another agent's message is not offered rewind (it would put the
  agent's text in the person's composer), and a Codex rewind keeps the
  sender on the messages it retains.
- The queued card and mobile name every sender, two that share a name
  included, and count in "+N" only those left out; the transcript row
  separates names with ", ".
- The host no longer normalizes the sender when loading the journal or
  the queue: nothing on the host reads it, and clients read it through
  the one reader at their projections.

* fix(native-chat): name a worker's final report by its task; clearer open-sender failures

- A worker's own worker_done settles its dispatch before the mail lane
  names who the report is from, so a terminal worker's completion read
  "Codex". A local worker is now named by the dispatch it holds, else the
  one it last held (handles are per run, so that is this run's). The
  coordinator mail rig now attests the worker's process, so its report is
  accepted and settles as it does in production.
- Opening a sender: a version block or an unknown answer says "Update Orca
  to open it.", since either side may be the older one; "This needs a
  newer Orca on the computer running this chat" stays only for a host
  that predates the lookup. "Orca couldn't reach the agent." is followed
  by "Try again.", each sentence translated on its own. No new copy.

* fix(native-chat): name a worker only by work it holds or just reported

A worker with no active dispatch was named by the newest dispatch ever
assigned to its terminal, at any status and any age: one that failed before
it ran, or a task finished long ago that its later, unrelated mail kept
showing. Now a worker is named by its active dispatch, else by the dispatch
its own worker_done in the announced mail names (only if that dispatch is
its own), else by its tab or agent as any terminal is.

* fix(native-chat): restore a rewound message's sender through its typed body; mobile card fixtures name their attribution

- The rewind's held-sender restore lives with the retained rows and reads both
  bodies through the journal's message-body check, instead of a shared helper
  taking any object (anti-slop: no-object-parameters).
- Mobile queued-card test fixtures carry the attribution field the card gained.

* fix(native-chat): never call a sender gone without proof; the sender builder moves out of the mail lane

- A federated sender (dispatch:<id>) runs on another host, which the chat's
  host cannot open: its name is plain text, and the host's lookup answers
  'not found' rather than 'gone' for a dispatch it does not run. Not seeing a
  worker is no evidence it stopped.
- The lookup calls a chat gone only when the identity is proven lost; any
  other failure reaches the client as 'could not reach'.
- A sender open that throws shows 'couldn't reach the agent' instead of an
  unhandled rejection.
- agentMessageSender, the one sender builder, lives in its own module, so the
  runtime's naming no longer imports from the mail lane.
2026-10-06 15:14:04 -07:00
Brennan Benson b922342199 feat(native-chat): the chat reads "Stopping…" from your Stop until the turn actually ends (#24369)
* test(native-chat): a Stop over a card sent now into the running turn keeps it paused

Red on main: Codex's turn end withdraws the steered hand-off and the queue
sends the card again as a new host turn, with no pause recorded.

* fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered

A card sent now into the running turn was still pending when Stop judged the
pause, so nothing was recorded; the interrupt then withdrew the hand-off, the
card went back to waiting unpaused, and the queue sent it again as a new turn.
The pause now counts a hand-off that may still return to waiting, judged with
the appended row applied, so a withdrawal lands under the pause and an
acceptance retires it in that same write. Codex and Claude both hit it.

* test(native-chat): the Claude re-send case fails on its diff, inside the test's budget

* test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it

The provider's answer, the provider dying, the chat closing, a restart, a
withdrawal still owed at open, and a /clear (refused until the hand-off ends,
then carrying every waiting card paused 'cleared'); each ends with the queue
sending again.

* fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card

* refactor(native-chat): derive the queue's pause from Stop and Resume journal rows

Stop now appends one journal row where it takes effect, before the interrupt,
whatever the queue holds; Resume appends its own. The pause is a pure function
of the fold: the latest Stop with no later Resume and no later accepted turn a
person asked for. A /clear's carried cards name their source, which is the
replacement's 'cleared' pause. Host-origin turns never lift either.

One predicate decides which cards a pause holds; by default every waiting card
without a hold of its own, including one queued after the Stop. The drain's
consume re-judges it inside its own transaction.

The rows are tombstones of an id no item takes, carrying the mark: a released
host reads an unknown row kind as corruption and truncates the journal there.

Deletes the stored pause (recordPause, the retire hook on every appended row,
the settle-before-record step, mayReturnToWaiting and its row overlay) and the
tests that only proved it retires. The queued_message_pauses table stays in the
schema, unread and unwritten, for downgrade safety.

* fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones

A Stop's pause now holds only the cards queued before its row, plus a steer it
withdrew, which returns to its own place. Each card records the journal
position it was queued at, and the one hold rule compares that with the Stop
row. A card queued after the Stop is a new instruction: it sends as usual, but
the drain still stops at the first held card, so it never overtakes them.
/clear's pause holds the cards it carried. Holding every card again is a
one-line switch in that rule.

* fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction

The drain's pick and its consume now read one function, nextSendableQueuedCard,
so a Stop row that lands between them holds a newer card behind an older held
one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.

* fix(native-chat): stop creating the unused queue pause table

The queue's pause is derived from journal rows, so nothing reads or writes
queued_message_pauses. It was still created on every open "for downgrade
safety", but an older build creates it itself when it opens the database, so
the table only sat empty in every new database. The tests now pin that no
pause table exists.

* fix(native-chat): a Stop's pause never hides the restart pause

A Stop holds only the cards queued before it. The pause derivation still
returned the Stop alone whenever it was in force, so the restart pause was
never considered: a card queued after the Stop, written by a host process
that has since exited, sent by itself after Orca restarted, with no pause
header and no Resume. A /clear pause that held nothing could hide it the
same way.

Every pause in force is now derived. A card is held if any of them holds
it, and it names the first that does. The drain's pick, the consume
transaction's re-check and the published header all read that one rule;
the header names the pause holding the first card Resume would send.

* test(native-chat): pin the Stop's no-resend, lift and held-card rules

- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent
  again" at one instant, before a queue ignoring the pause re-sends. They
  now wait for the stopped turn to end and re-check after a quiet window.
- The deleted-card test read a card queued after the Stop, which sends
  whether or not a person's turn lifts it; it now reads the Stop's pause
  before and after that turn.
- Unit cases pin that a Stop holds a card with no recorded position and one
  queued before a rewind.

* refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller

The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.

* test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event

* test(native-chat): pin that Stop and Resume rows never reach apps or count as history

* test(native-chat): only a person's Stop event pauses the queue

* test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop

Through the real host: the event names the turn and who asked and is in the
journal when the interrupt reaches the agent; at an agent still starting it is
there before the start is ended and holds a card queued before it; an idle Stop
writes one only when it withdrew a send; and the queue's claim re-judges a
pause that landed after its pick.

* test(native-chat): a card held at a starting agent is checked before the Stop's timing

Also says precisely what the claim's in-transaction pause check defends
against: the Stop and the drain share one serialized lane.

* test(native-chat): a released build keeps and folds a journal holding Stop events

Replays this build's rows from the released build's own journal database: every
row is kept, the history after the Stop still folds, and an older client is sent
only removed ids no item uses.

* style(native-chat): format the Stop event changes

* test(native-chat): type the released build's exports through one checked helper

* fix(native-chat): the Stop/Resume row guard narrows to those tombstones only

* test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade

The Stop-event downgrade test ran in no CI lane: unit shards exclude the
cross-version folder, and the cross-version lane runs a fixed file list that
did not name it. It is now on that list.

Its only case replayed the rows into a release's own fresh database, because
that release cannot open the current host database. A second case opens the
journal this build wrote with a main build that shares the database: it opens
writable, keeps every row, appends, and this build then reopens it with the
person's Stop still pausing the queue.

* fix(native-chat): a Stop that stops nothing new writes no Stop event

A Stop reaching a running agent wrote a Stop event on every press. Two
presses before the first interrupt landed wrote two events, so a card
queued between them counted as before the latest Stop and was held,
though a card queued after a Stop should send normally. A Stop naming a
turn that had already ended, as a phone sends late, also wrote an event
for a turn it never stopped.

It now writes one only when it withdrew a queued send, or stops something
no event records yet: not a turn the journal no longer runs, and not the
live turn a Stop still in force already names, unless a card was handed
over into it since, which this Stop's interrupt sends back and must hold.
The interrupt and the "already finished" note are unchanged. A Stop at a
starting agent still always writes.

* test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop

* chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled

* fix(native-chat): any later Stop event ends a person's Stop pause

A person's Stop paused the queue until their next accepted turn or Resume,
and a later Stop of another reason (the host stopping the agent, an
eviction, a close) was ignored. Now the pause is the latest Stop event's:
a later Stop of any reason ends a person's pause, and only a person's Stop
pauses. The fold keeps the latest Stop event whatever its reason.

An eviction of a resting chat writes no Stop event (a Stop that stops
nothing writes nothing), so it cannot release held cards; a test pins that
no event means no lift.

* fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed

A Stop pressed before the agent's turn shows in the journal (before
Claude's echo, or before Codex opens the turn) records no turn. A second
press once the turn showed compared that missing turn with the live one,
wrote a second Stop event, and held a card queued between the presses.

A repeat is now judged by what was sent since the Stop in force: with
nothing sent after it (a refused send aside), a Stop that named no turn,
or named the live one, is repeated and writes nothing. Anything sent since
and not refused, including a send whose fate is unknown, makes the new
press write, since its interrupt may send that card back to waiting.

Tests: the two-press case across the turn showing; a steer between the
presses settled unknown; and a Stop naming a turn that ended while the next
card is sent but shows no turn yet, which writes and holds that card. The
fold test that claimed an eviction path is renamed.

* fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes

A Stop or Resume row's value is read from disk with no shape check, and
the pause fold stored whatever it found. A stored `stopEvent: null` would
then throw on every pause check for that chat: the queue's pick, its
send, and every queue update to clients. No build writes such a row, so
this is hardening.

The fold now reads a Stop only when it is an object with a string reason
and a finite time, and a Resume only when it is `true`. Anything else is
ignored: it pauses nothing and ends nothing. The row is still not treated
as malformed, which could cut the history short.

* fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends

Every stop that ends work now writes the Stop's event before it ends the child: a
person's close of the chat, an eviction (worktree teardown, orchestration stop, tab
cleanup) and the idle sweep's stop of a start that never landed. A stop that ends
nothing writes nothing, and quit writes none: its resume marker records why.

The turn-end write reads the latest Stop event where every turn row is built, so the
adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a
person's Stop or close named, ending with no verdict of its own after that Stop, ends
as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that
found the turn running. When the provider refuses the interrupt and the turn runs on,
a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop
again after a refusal is a new Stop.

* refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event

The cause of a stop was threaded in memory from each entry through the host's stop
step, the adapter router and each adapter's close onto the `ended` it settled with,
and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters
settle a turn they cut as interrupted with no verdict, the host's fallback does the
same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the
journal's latest Stop event to say whether it was a person's.

- `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`.
- Claude reads an error result after a person's Stop as their cancellation from the
  journal's Stop event (through the event sink), not from a per-turn slot, and a
  refused interrupt is the host's refusal row, not `withdrawTurnStop`.
- An owed wind-down keeps no cause: its retry's fallback reads the Stop event.
- The mutation context's Stop passes no cause: its step already wrote the event, and
  the delivery loop's child-end reason is read back from it.
- A Stop pressed before its turn showed applies to the turn that opens under it,
  unless a send a person made since was accepted.

* test(native-chat): a turn a later send opened is no Stop's that named no turn

* test(native-chat): the restart test's death proof carries its detail

* refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names

The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to
a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that
stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and
the Stop names that turn, so the turn running instead never reads as the person's by its id alone.

* fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next

A Stop that named no turn read as the person's cancellation for every later turn that opened
after it, until a send a person made was accepted. The queue's drain, orchestration mail and a
restart continuation send as the host, so a turn they opened long after, cut by a crash, read
"Interrupted" as if the person had stopped it. The Stop now applies only to the first turn
opened after it.

* fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted

Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The
translator judged whether a person's Stop explained it by its own copy of the Stop rule, which
ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed".
The translator now writes such an end as interrupted with no verdict and no error row whenever a
person's Stop may name the turn, and the journal's one rule decides as it writes the end.

* fix(native-chat): a person's Stop and /clear each name why they end the agent

The host's mutation path ended the agent with one "recorded" ending for every caller, which read
back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop
event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop
`user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat.

* fix(native-chat): a host stop judges whether it ends work after the provider's rows land

A close, eviction or host stop decided whether it ended a running turn from the journal as it
stood, while the provider's own rows (the turn its echo opened) could still be in the session's
event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It
now reads after the sink drains, as a person's Stop does, through the same check; a drain that
fails or takes over a second reads working.

* fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts

A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed,
Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended
turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.".
A Stop that ends the provider's session ends whatever is in flight, so its event now names the
live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only
the turn the Stop names.

The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at
its line budget.

* fix(native-chat): the idle sweep reads working by the same rule as a stop's event

The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the
stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event,
which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed
work now reads the main agent working the way every session list and the event writer do.

* test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain

A host stop now drains the session's sink once to judge whether it ends work, so the tests that
fail the eviction's drain-published step skip that first drain.

* fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes

The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose
admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule
returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop
event, whatever send it retires: a person's Stop pause holds through it.

* fix(native-chat): stopping a start that carries no send writes no Stop event

A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried.
A start with a send already reads working, so the clause only mattered for a start with none,
which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle
clock, which is why the idle sweep had been changed to close the conversation in the same pass.
The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before.

* test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event

The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs
and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop
pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns,
restart as a process that dies with no close, which like a quit writes no Stop event, and assert
that both the Stop's and the restart's pauses are in force first.

* fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason

An eviction or host stop that landed while a person's Stop or close was already ending the same
turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of
that turn read as news. A host reason now writes nothing while a person's Stop still decides what
runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's
own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does,
so the mail turn after it is not the turnless Stop's.

* fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop

The translator left an error result that names no reason to the journal's Stop rule whenever a
person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn
opened next. So a real error on a later turn read "Failed" with its error text dropped. The
translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core
`turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree.

* fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it

A person's Stop pressed while the agent starts names no turn, and the send it stopped is
cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration
mail, a restart continuation, the queue's drain, all of which send as the host), so a host
eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A
Stop that named no turn now binds only a turn no send journaled after it opened: any send since,
of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex
accepts it, instead of opening the stopped send's own turn first.

* test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind

A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new
sequence, so by sequence alone it would bind the next turn opened after the rewind. A send
journaled after the restated row voids that binding (the previous commit), which this pins.

* fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row

After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but
the relaunch still added the error row saying the provider stopped mid-response, which a live Stop
never writes. The settle now skips that row when every turn it interrupts is the person's Stop's
by the journal's one rule; a crash nobody stopped keeps it.

* test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn

* fix(native-chat): a host stop whose sink drain fails reads the journal as it stands

A host stop drains the session's sink before judging whether it ends work, and a failed or slow
drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing,
which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is
now best effort: the stop goes ahead either way and only its record is at stake, so a failed or
slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule.

* fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened

A person's Stop pressed before any turn showed names no turn. It bound the first turn opened
after it, then (5ead1f6bcc) any turn opened by no later send, so a turn the host started for
a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host
eviction of it wrote no Stop event when its send had been abandoned by the close first.

The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it
stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's
row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal
aliases to the submission), and a handed-over send's item sits at its handover, so the target set
is derived from the journal. A card the Stop held is handed over after it, so it is no target; a
Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a
restated Stop binds no turn opened after it. With no turn running, a host stop defers to the
person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks
before its echo row lands, passes the send its echo acknowledged.

* fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal

A drain past its bound may still hold the turn's row, while the echo's acceptance has already
landed, so the journal as it stands read nothing running: a person's close of that turn wrote no
Stop event and the turn read as news. The two drain outcomes now differ: one that failed has
nothing more to deliver, so the journal's read holds (as before); one still running reads working.

* test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's

The branch had no test. An eviction with only the stopped send unanswered writes nothing; one
with a send made after the Stop still unanswered writes its event.

* fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due

The previous commit read every drain past its bound as working, so a host eviction or stop of an
agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's
Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened
no turn the journal holds, the race it was for; otherwise the journal's read holds.

* test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one

With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule
that deferred when any one was would pass the old control.

* fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop

A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or
a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last
send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered
into a running turn, whose item carries that turn's scope, are skipped.

* feat(native-chat): a chat reads Stopping from the person's Stop until the work it stopped ends

The host derives it on each journal publish from the Stop's event, the live turn and the Stop's
own answer, and publishes it as an optional field on the session status and the main agent's row.
Clients present it: the chat's tail line and Stop control, the sidebar row, worktree ps and the
phone's row. The chat and the phone also read their own Stop press until its request answers.

* test(native-chat): pin Stopping on the phone and across mixed versions

* test: give touched fake journals and mocks their SAFETY notes

* test(native-chat): a turn waiting on the person reads attention, never Stopping

* test(native-chat): the sidebar row follows Stopping when it is the only field that moved

* test(mobile): the phone reads Stopping from its own Stop until the request answers

* test(mobile): type the held Stop request instead of casting it

* chore: keep the base lockfile (a local pnpm run rewrote it)

* fix(native-chat): narrow the Stop note's optional failure; type the phone test's reply

* test(native-chat): type the Stop test envelope's fields narrowly

* fix(native-chat): a Stop the agent declined, or whose child end failed, says so while the turn runs on

A Stop naming the turn that still runs, refused by the agent, now writes the
Stop's refused fact instead of 'already finished'. A session-ending Stop whose
child end fails while the work runs on revises its note to unconfirmed.

* fix(native-chat): Stopping holds while any press of the Stop took

A repeat press refused after an earlier press took no longer clears Stopping.
Exit early when the Stop named a turn that is not the live one.

* fix(native-chat): keep Stop enabled while the host says Stopping

Only this client's own Stop request in flight disables Stop and Esc. A repeat
Stop is how a stop the provider took but never answered escalates.

* fix(sidebar): every agent row says Stopping in place of its tool line

The dashboard row, which the sidebar's non-compact mode also draws, read the
last tool line while a person's Stop ended the turn. It now shares the compact
row's rule.

* fix(mobile): the worktree list sees Stopping change on its own

A snapshot whose only change was the host dropping Stopping compared equal and
was thrown away, leaving the row on Stopping.

* refactor(native-chat): fold the status feed in src/shared for both clients

The snapshot merge and the contact-loss strip move out of the renderer feed so
the phone folds the same stream the same way.

* feat(mobile): let phones read the structured session status stream

agentSession.subscribeStatus joins the mobile allowlist. The agent-session
methods move to their own file, which the at-cap allowlist spreads in, and the
allowlist test reads the Set instead of parsing the source.

* feat(mobile): the phone chat reads Stopping from the host, like the desktop

One status stream per client, opened on a host that advertises the status feed;
a refusal to phones reads as no feed. The chat reads Stopping from the host or
its own press, and holds Stop only while its own request is in flight.

* test: give the new fakes checked types or a SAFETY reason

* revert(native-chat): drop the refused-named-turn rewrite of a Stop's note

Codex can send its refusal before the turn's end frames, so reading the turn as
still live after a flush races; the Codex Stop that ends nothing is handled by
ending the process instead. The base's 'already finished' note and its test
expectation return. The Claude wind-down failure revise stays.

* fix(mobile): release the status stream when the host ends it; refusals last one connection

The feed now drops the handle of a stream the host ended or refused, so the
logical client never replays it on a later session. A refusal to phones holds
for one connection, so a host updated while the phone stays paired is asked
again.

* perf(native-chat): read the live turn's opener from its record when deriving Stopping

After a Stop that named no turn, every later commit walked and copied the whole
journal to find the live turn's record. The derivation now reads that record
off the rendered snapshot's tail and decides with the same rule.

* refactor(mobile): move the method-unavailable check into transport

The status feed imported it from the Files tab's fallback. No behaviour change.

* test(native-chat): a send after a Stop reads Working before its turn opens

Pins the derivation's running-only read of the newest turn: the stopped turn,
already ended, must not keep the next send on Stopping.

* fix(sidebar): the compact row leads with Stopping so a narrow sidebar keeps it whole

At the default width the row read 'Codex Chat - Stoppin…': the model and time
keep their room and the line truncates from the end. Stopping now leads the line
the way monitoring already does, so the chat name is what gets cut. Also pins
that the turn bar keeps its running clock while the tail line says Stopping.

* test(native-chat): the retry of a close whose exit was unproven writes no second Stop event

The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its
event stands alone and the child's end keeps the cause, for a person's close and an eviction.

* fix(native-chat): read and write a Stop's answer by the turn its event records

Stop notes are now one row per turn, keyed by the turn the Stop's event
records. Performing a Stop and deriving Stopping share that key. A refused or
unconfirmed answer never overwrites one that took at the same key; only a
session-ending Stop's failed wind-down downgrades it, and that step now revises
the note the Stop actually wrote, carried on the wind-down. Stopping reads the
turn's note whenever it was first written, plus newer notes no other turn owns.
Test fixtures gain the host logger and the phone's quietRepeatedStop.

* refactor(native-chat): read a Stop's target once for its event and its note

The chat's Stop now reads what it is aimed at (the named turn and whether the
Stop ends the provider session) once, and both its event and its note's key
derive their turn from that one value through the same rule. Adds the host test
for a Stop naming an ended turn on a provider whose Stop ends the session.

* fix(native-chat): the host never steers a message into a turn a Stop is ending

A queued card's Send-now, or a send made while a person's Stop ends the turn,
went to the agent as a steer into that turn. The delivery loop now holds any
waiting message while the host's own Stopping reading holds, and sends it as
its own turn once the turn ends. The Stopping reader also stops at the Stop's
position and looks the turn's note up by key, instead of walking the whole
journal.

* feat(native-chat): while Stopping, the composer says a message runs after the stop

Desktop and phone: the composer placeholder reads "Queue a message to run
after the stop" while the chat reads Stopping, and a queued card's Steer (and
the desktop's steer shortcut) is held. New key translated in all 6 catalogs.

* refactor(native-chat): the chat pane's Stop controls live in their own module

The pane went over its line limit once merged with main. Its Stopping reading,
the press that holds Stop, and the steer and placeholder it hands the composer
move to native-chat-structured-stop-controls.ts.

* fix(native-chat): hold a send at its handover, reading the feed's own Stopping

The hold was checked when the delivery step started, but the handover runs in a
later step after waiting on the agent's start, so a Stop landing in between let
a new send steer into the stopping turn. The check now runs at the handover.
It reads the status feed's projection for the commit instead of rendering the
journal again, so holding a send adds no journal read of its own.

* refactor(native-chat): one display status decides Stopping on every surface

agentStopDisplayStatus combines whether the agent works, the host's flag and
this client's own press. The chat pane, sidebar and dashboard rows, and the
phone's chat all read it, instead of each combining the flags.

* fix(native-chat): Stopping holds until the stopped turn ends, whatever the Stop's answer

A Stop the agent declined, or whose end went unconfirmed, used to drop the chat
back to Working. It now stays on Stopping until the turn ends, and Stop stays
enabled so a repeat press escalates. The Stop's answer is no longer read for
Stopping, so its note goes back to the key the base gives it (the restore of
queued-stop.ts and the removed key test landed in the previous commit). The
note still keeps a press that took over a later refusal, and a failed process
end still says the Stop went unconfirmed.

* fix(native-chat): a Stop binds only the turn it actually stopped

A Stop pressed before any turn showed used to claim, at end-write time,
whatever turn the stopped send later opened, even when the Stop stopped
nothing. A turn that then died on its own read as "Interrupted" (your
cancellation) instead of "Failed".

Now a person's Stop that named no turn binds, in memory only, every turn
that ends while the Stop settles, and afterwards only the turn its
interrupt took. The settle ends a still-running stopped turn once. A
relaunch finds nothing in memory, so an unsettled turnless Stop binds no
turn. A Codex Stop whose answered turn does not open within its wait, or
whose send's answer was lost, now answers refused, so the host ends the
child and the turn can never run.

* fix(native-chat): keep the person's queue pause and close binding after a Stop settles

A host stop or eviction with no turn running now defers to a person's
Stop while its queue pause still holds with nothing sent since, read from
rows, so a held card is not handed off on reopen after a Stop that did
nothing or whose kill failed.

A person's close that named no turn opens a settle around its child's
end, so a turn that end cuts reads as theirs.

A press opens its settle only when the latest Stop event is its own or
the one in force it repeats: a late Stop, a card's interrupt or a lost
event row reopens no earlier Stop.

A Codex Stop that cannot reach a turn still able to open says the Stop is
unconfirmed rather than that no turn ran, and a second Stop still reaches
a turn an earlier wait left unopened.

Also drops the unused openedBy plumbing and the unreachable "a written
cancellation stays one" rule, and pins a relaunch after a named Stop.

* fix(native-chat): keep a failed Stop's turn display-only, and settle edges off the commit path

A Stop that failed marks the turn it could not stop for "Stopping…" only
(JournalStopSettle.failedOn): no turn-end rule reads it, so that turn's
own end with no verdict reads as a failure, not the person's.

A settle edge writes no row, so it no longer goes through the journal's
commit listener, which also delivers history, counts as activity for the
idle sweep and schedules the queue drain. A narrow settle-edge hook
republishes the status row and wakes the steer hold's handover, and
nothing else.

* fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle

A Codex Stop that waited for a turn Codex answered a send into now answers
"may still open" whenever that turn neither opened nor ended and its send
is still owed, however the wait ended (it ran out, or the thread went
idle). Before, a first press after an idle thread said no turn was
running and kept Codex, while an identical second press ended it.

Adds a test that a person's close the conversation outlives (as /clear
does) closes its settle, so a later turn that ends on its own reads as a
failure.

* fix(native-chat): a Stop that failed before its turn showed still reads Stopping through that turn

A Stop that failed with no turn open marked nothing, so the chat dropped
to Working and the turn that then opened never read "Stopping…". The
display-only mark now also covers that case: the first turn that opens
after the Stop failed, provided no message was handed to the agent in
between. No turn-end rule reads the mark, so that turn's own end with no
verdict still reads as a failure.

* test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop

With one ordered journal writer the Stop's event is in the fold when its
write returns, so the press reads whether it owns the latest Stop from the
fold instead of awaiting the write. Pins the case the read must refuse.

* test(native-chat): name the settle, not a stream drain, in the Stop's own-end test

* test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout

Codex answers an interrupt it took before it sends turn/completed (interrupted):
on TurnAborted the app-server answers pending interrupts, then ends the turn, on
one channel. The test fake did the reverse. It now answers first and ends the
turn on a later read, and the tests that read the turn's end right after a Stop
wait for it.

New end-to-end test through the shipped host, journal and Codex adapter: with
the real order, every end row of the stopped turn reads interrupted by the Stop
(named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the
settle window turns the in-flight case red: the Stop's own end row then has no
verdict, which reads as failed until Codex's end lands.

* test(native-chat): Stopping ends with a Codex turn whose interrupt is answered before its end

With Codex's real order (the interrupt's answer, then turn/completed interrupted),
the status shows Stopping while the Stop settles, drops it once the turn ends, and
never carries a verdict other than the person's cancellation.

* fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once

A Stop that named no turn, made after Codex answered a send but before the turn
opened, used to wait up to 5 s for the turn to open before interrupting, and
ended the Codex process when it didn't. The stated reason, that Codex refuses an
interrupt until it opens the turn, holds only part of the time: with no turn
active, Codex takes an interrupt once its thread runs (turn_interrupt_inner),
and refuses it with -32600 "no active turn to interrupt" before that or once
the turn has ended.

The Stop now sends the interrupt at once. Only on that refusal, while the turn
has neither opened nor ended, does it wait for the turn to open (bounded at
5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One
that never opens, or that an earlier wait already gave up on, fails the Stop,
and the host ends the child as before. Sends still wait for the turn to open
before steering into it.

The test fake models Codex taking an interrupt once the thread runs (run()).

* fix(mobile): name how the phone's status stream is released in the subscription inventory

Main made each inventory entry state its release; the status feed's stream is
released from its subscribe params, as the session event stream is.

* fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did

A Stop whose interrupt Codex refused as finding no active turn skipped the wait
when an earlier wait, a Stop's or a send's, had already given up on that turn.
Every press now waits its own bound and retries once if the turn starts, so a
turn that opens during a later press is still stopped. Both presses still reach
the same verdict when it never starts.

* fix(codex): never steer a turn whose interrupt Codex answered

Codex answers an interrupt as the turn aborts, before it sends that turn's
turn/completed. In that gap the adapter still counted the turn as running, so a
message handed over right after a Stop settled (the Stop's own end row already
reads the turn ended) went out as turn/steer, which Codex refused with -32600
"no active turn to steer", and only then as turn/start. The adapter now marks a
turn whose interrupt Codex answered as aborted until its turn/completed, never
steers into it, and starts the message's own turn directly. Steering a turn
that is genuinely running is unchanged.

* fix(native-chat): a message sent while Stopping is queued as a card, whatever the setting

While the chat reads Stopping (the host's flag or this client's own Stop in
flight) there is no turn left to steer into, so the desktop asks the host to
queue the send even with the queueing setting off, and it is never drawn as a
bubble inside the turn being stopped. The phone already queued every send on a
capable host; a test now pins that it does so while Stopping.

* fix(native-chat): a message queued while Stopping is a card at once, not after the stop

A person's Stop holds the session's lane until Codex answers its interrupt, and
a send was admitted only behind it. By then the turn read ended, so a send that
asked to be queued went out plain: no card for the whole of Stopping, then a
bubble and a new turn.

While the host reads that a person's Stop is ending the work, a text send that
asks to be queued is admitted without waiting for the lane: the same ledger and
lease admission, and a plan that only writes the card through the journal's
ordered writer. The card runs when the stop lands; the Stop's pause holds only
cards queued before it. Anything else, including a Stop that settled by the
time the send runs, takes the lane as before.

The Codex test fake now drops the active turn when it takes an interrupt, as
Codex does before it answers, so a turn/start after the answer opens a new turn.

* fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send

A Stop on a Codex turn that was answered but never opened ends the Codex
process. That end settled the send as in doubt (unknown, recovered), and the
client's outbox holds every later send behind a send in doubt until the person
presses Retry, which re-sends the very message they stopped. The chat looked
stuck.

Codex records a prompt only once its turn has started, so a send whose turn
never opened never ran. When the Stop's refusal says so (turnMayOpen), the child
end now settles the unanswered sends as withdrawn, the verdict Codex's own
interrupted-turn end already gives an unechoed send. The flag rides on the owed
wind-down, so a retry after a failed child end withdraws them too. Claude's
child end still leaves its unanswered send in doubt.

* fix(native-chat): derive the withdrawal of a Codex send whose turn never opened

Replaces the flag the Stop carried to the child's end, and its copy on the owed
wind-down, with a reading of the journal at the settlement that lands. A Codex
child's unanswered send is withdrawn when a person's Stop is in force since it
was sent and no turn row ran, or was written, after it; any other end (a turn
that opened, a host's close, a crash, Claude) still leaves it in doubt. A
retried wind-down reads the same rows, so it withdraws the same sends.

* fix(native-chat): a card queued while Stopping runs past the cards the Stop holds

A card queued before a person's Stop waits under its pause until Resume. One
queued after it, as a message sent while Stopping now is, was stuck behind them
too, since the queue never reorders. Such a card was asked for after the Stop,
so it runs when the stop lands, past the cards held only by that Stop's pause;
a returned card and the restart and /clear pauses still hold everything behind
them.

Also: the host's own Stopping reading is gated on working, as the published
flag is, so a failed Stop's mark never reads Stopping on an idle session; a send
that falls back to the lane re-reads the conversation's journal there; and the
end-to-end test asserts the queue's pause rather than a per-card field.

* fix(native-chat): a paused queue labels only the cards it holds

Since a card queued after a person's Stop runs past the cards the Stop holds,
labelling every card "paused" while the queue's pause is published misreads that
card. The host now marks each card its pause holds (heldByPause, a new optional
field), and the desktop and phone label only those. An older host marks none,
so a client keeps today's labels; an older client ignores the field.

Adds a test of the desktop's own send through the real outbox: while the chat
reads Stopping, the request asks the host to queue it and no bubble is drawn,
on a host that advertises the queue.

* revert(native-chat): defer the per-card queue pause label to the queue's rollout

The heldByPause field and its labels are visible only where the host
advertises the queued-messages capability, which shipped hosts do not yet do.
Deferred to that rollout; the real-outbox send test stays.

* fix(native-chat): while Stopping, say and show what a send does where the queue is dark

Shipped hosts do not advertise the queued-messages capability, so a message
sent while Stopping goes out plain: the host holds it until the stopped turn
ends and then runs it as its own turn. The composer still said "Queue a message
to run after the stop", and the message was drawn inside the turn being
stopped.

Now the placeholder reads "Send a message to run after the stop" where the host
does not queue sends, and "Queue a message…" only where it does (desktop and
phone, all six catalogs). A send this client made that the host has not
recorded yet is drawn after the Stopping line while the chat reads Stopping, as
a message held behind a running command already is; once the host hands it
over it opens its own turn. Client presentation only.

* fix(native-chat): keep a send in doubt when a turn was open for it

The derived withdrawal read a turn as open for a send only if it still ran or
was written after the send. A send steered into a running Codex turn whose
interrupt failed met neither once the adapter's end settled that turn ahead of
the host's settle, so it read withdrawn, though Codex drains a steer into the
running turn and may hold it. A turn that ended after the send was handed over
was open for it too: such a send stays in doubt, as before.

Pins that case, and that a send made after the Stop, to a child that then dies
before its turn opens, stays in doubt.

* fix(native-chat): restore the per-card queue pause label

Kept after all: a paused queue labels only the cards it holds (heldByPause),
which is visible only where the host advertises the queued-messages capability.

* fix(native-chat): draw only a send made while Stopping after the Stopping line

Every send the host had not recorded yet was drawn after the Stopping line,
including one made just before the Stop, which the host steers into the turn;
it then jumped up into that turn once recorded. The outbox now marks a send
made while the chat reads Stopping, and only those wait after the line.

* fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing

Whether a turn was open for a send was read from end times: a turn that ended
after the send's handover counted. A send made while a Stop ended the turn is
handed over once that turn reads ended, yet Codex's own end for it can arrive
later, so such a send whose own turn never opened read in doubt again, and the
chat's queue held behind it.

The handover already records where the send went: its message joins the turn
running then (a steer) or belongs to no turn (it starts its own). Only a send
that started its own turn, with none opened since, is withdrawn; one that
joined a running turn, or has no recorded place, stays in doubt.

The Codex test fake takes an answered interrupt as Codex does, dropping the
turn before its end arrives.

* refactor(native-chat): move queue-while-stopping to its own follow-up

The queued-messages capability is off on every shipped host (#21062), so the
parts of this PR that act only when it is on move to a follow-up stacked on
this one: admitting a queued card while a Stop holds the session's lane, a card
queued after a Stop running past the cards it holds, the per-card pause mark,
and asking the host to queue a send made while Stopping. This PR keeps the
Stopping state, the host's steer hold, the rule that never steers a turn whose
interrupt Codex answered, and what a send while Stopping looks like where the
queue is off.

* fix(native-chat): leave no Stop row when the Stop took back a send that never ran

A Stop on a Codex send whose turn never opened ends the child, and the child's end
takes the send back into the composer. The Stop still wrote "Cancellation
requested." at the conversation level, so with the send gone it sat under the
previous finished turn and read as if that turn had been stopped. A Stop that found
no turn running and whose child end took back every send it found now writes no
row; a Stop of a running turn, or one that leaves a send in doubt, still does.

* fix(native-chat): count a send whose answer was lost when a Stop takes it back

The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart.

* test(native-chat): pin which sends a Stop's child end can take back

A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted.

* fix(native-chat): read the host's Stopping beside main's startup phase

Main now reads only the startup phase from the status feed and no longer publishes which
child is starting. The chat reads the host's Stopping from its own hook beside it, and the
Stopping bridge test mocks the execution-host lookup main's owner resolution now calls.

* test(native-chat): read the outbox reconcile from where main moved it

* fix(native-chat): a retried message no longer waits behind a later Stop

Retry dropped the Stop it had outlived but kept the mark that it was sent while
a Stop was ending a turn, so a retried message waited behind whatever later,
unrelated turn a Stop was ending. Retry is a new send: drop that mark too.

* fix(native-chat): word a send after a Stop by whether this send will queue

The 'queue a message to run after the stop' placeholder read the host's
queue capability alone. A send queues only when the host queues and this
send asks it to: the queue setting is on and no pending prompt blocks the
queue. Desktop and phone now word the placeholder from that same decision
their send uses.

* test(native-chat): one test per case for the words of a send after a Stop

* refactor(native-chat): the dictation hook owns the composer's dictation state

Keeps NativeChatComposer within its line limit after the Stop props and
main's /context answer both landed in it.

* refactor(native-chat): name the dictation hook for what it owns now

* fix(native-chat): a Stop's note says it took once a joined close proves the exit

A session-ending Stop whose child's end failed revises its note to
'unconfirmed'. Since main's #24862, the next Stop joins that close rather
than stopping again, and wrote no note, so a close that then proved the
exit left 'unconfirmed' under a turn that ended. The close now carries
the note it settles, and its proven exit revises it to 'Cancellation
requested.'. A join that fails again leaves it unconfirmed.

* fix(native-chat): a proven turn end says a Stop's unconfirmed note took

Replaces the note carried on the child's close. Every settlement that
ends turns interrupted on a proven exit, live or after a crash, also
revises an unconfirmed Stop note on those turns to 'Cancellation
requested.', found by the note's turn scope, in the same batch. A note a
Stop wrote before its turn showed is re-keyed onto the running turn when
it becomes unconfirmed, in one batch, so that end finds it. Known limit:
with no turn open yet, the note keeps its key and no turn's end revises
it.

* test(native-chat): a Stop's unconfirmed note says it took when the agent exits on its own

* test(native-chat): name the Codex handle as main's opaque handle does

* test: restore the provider handle import the main merge dropped

* fix: derive Stop note wording from interrupted turns

* test: name the raw replay case for what it covers

* refactor(native-chat): move the waiting-slot split into its own hook

* test: follow main's chat font-size rename in the stopping tests

* test: follow main's single live-line value in the Stopping tests

* test: give the android live-line fixtures the stopping field

* chore: keep the session host under its line limit after the main merge

* chore: keep the composer test and the phone chat view under their line limits after the main merge

The Stop control now disables itself while Stopping, so the composer passes
the flag through and its test file stays as main has it. The phone chat
header's Stop moves to its own component.

* perf(native-chat): read a Stop note's fields before parsing its key on every snapshot

Every snapshot projects each item through the Stop-note read, and each new
snapshot rebuilds the index of Stop notes by turn. Both parsed every item's
key first; they now check the row's kind and turn scope (and, for the
projection, its unconfirmed-stop failure) before the parse. Every Stop note
is a status row, so what each finds is unchanged.
2026-10-06 12:16:29 -07:00
Brennan Benson defd85a9eb feat(floating-workspace): render the floating panel through the shared workspace surface (#22302)
* docs(floating-workspace): plan structured native chat in the floating panel

* feat(floating-workspace): resolve the floating workspace so a structured session can run there

* docs(floating-workspace): plan the unification onto the shared workspace surface

* chore(floating-workspace): keep the unification plan out of the branch

* test(floating-workspace): pin the structured route on capability, not workspace kind

The host half removed the 'floating-workspace' blocker; this pin still asserted it and went red
on the merge. Floating now routes like any workspace, so with capabilities un-negotiated the
resolver answers 'runtime-capability-unknown'.

* feat(floating-workspace): resolve the floating workspace's renderer row without a worktree record

The renderer had no Worktree row for the floating id: markdown creation, tab-bar path entry,
native-chat file links, attachments and model discovery all resolve a workspace through
getKnownWorktreeById and silently fail for it. The host already answers with a synthetic row
minted from the resolved floating directory; this gives the renderer the same single seam.

- The repo slice stores the host-resolved floating directory (floatingWorkspacePath); the panel's
  existing cwd resolution now writes it there instead of panel-local state, so there is one copy.
- findKnownWorktreeById mints the shared floatingWorkspaceToWorktree row from that path, local
  host only, cached while the directory is unchanged — mirroring the folder-workspace branch it
  sits beside.
- No consumer changes needed: every row-dependent path already goes through this resolver.

* feat(tabs): scope editor and browser activation to the owning workspace

Shared editor/browser activation always wrote global selection (activeFileId,
activeBrowserTabId, activeTabType). The floating panel is an overlay above the active
workspace, and its active tab must never become global selection — the invariant
documented in agent-auto-ack-targets.ts. Group-scoped activation is a prerequisite for
rendering the floating workspace through the shared tab-group surface.

- setActiveFile and setActiveBrowserTab take an optional target workspace, mirroring
  setActiveTabType: global selection moves only when the target IS the globally active
  workspace; per-workspace state always updates. Omitted keeps legacy behavior.
- The shared tab-group activation/creation commands pass their workspace, which is a
  no-op for the main surface (hidden surfaces are inert, so commands only fire on the
  active workspace) and confines floating activation to floating state.
- Terminal setActiveTab was already owner-scoped; now pinned by test.

* refactor(tab-group): separate the workspace drag scope and group tree from main-window chrome

Preparation for hosting the floating workspace on the shared surface, whose shell owns its own
titlebar tab strip and cannot mount the main chrome:

- WorkspaceTabDragLayer: the one dnd-kit scope for a workspace's strips and tree, extracted from
  TabGroupSplitLayout as a render-prop layer so a host can put shell-owned chrome (a titlebar
  strip) inside the same drag scope as the tree.
- TabGroupSplitNodeTree: the chrome-free recursive group tree, with an explicit tabStrip
  composition point on TabGroupPanel ('attached' keeps today's per-group strip; 'external' hosts
  render no per-group strip at all — a hidden one would register duplicate drag sortables).
- WorkspacePaneOverlayLayers: the retained pane hosts extracted from WorktreeSplitSurface so any
  tree host mounts the same pane lifecycle stack.
- TerminalPaneOverlayLayer gains ownsNativeChatToggleShortcut, following the existing rule that
  only one live workspace registers the chord.

TabGroupSplitLayout and WorktreeSplitSurface recompose these parts; the main surface's DOM is
unchanged.

* feat(floating-workspace): decide the panel's emptiness and layout atomically

An empty floating workspace has no tab-group layout at all (the slice starts empty and
hydration skips tab-less workspaces), while the shared group tree requires one. This model
resolves the two together: the panel shows its empty state exactly until the first tab exists,
and mounts the tree only with a layout plus a live focused group (stored focus, else the group
with an active tab, else the first group — matching the shell chrome's focused-group policy).

* feat(floating-workspace): render the floating panel through the shared workspace surface

The panel previously projected exactly one tab group and hand-rendered its panes, so any tab
that left that group — 'Move Tab to Split' creates a second group — stayed alive in state but
vanished from the panel. The body now mounts the shared group tree and retained pane overlay
stack for the floating workspace id, so every group renders, splits work, and structured chat
finally has a floating surface.

- One drag scope: WorkspaceTabDragLayer wraps the titlebar strip AND the body tree, so exactly
  one DndContext owns floating tab drag; strip drops reorder, body-edge drops split. The
  floating-only FloatingWorkspaceTabDragContext is deleted.
- The titlebar keeps the panel's single TabBar, now fed by the shared per-group workspace model
  of the focused group (tabStrip='external' keeps per-group strips out of the shell), with
  workspace-scoped activation commands so global selection never moves.
- Panes render through WorkspacePaneOverlayLayers: terminals gate on the resolved cwd and a
  settled viewport exactly as before; browser guests ride the shared retention layer; agent
  session tabs appear in the strip and render through the structured overlay.
- The empty state renders exactly until the first resolvable tab exists (stale unified entries
  still show it), per the atomic layout/emptiness model.
- Deleted: the single-group projection, the hand-written pane maps, FloatingBrowserSlot, and
  the floating pane-handle registry.

Known behavior deltas, both narrowing floating onto main-workspace semantics: Cmd/Ctrl+W with
panel (not terminal) focus closes the active tab rather than one pane of a multi-pane tab, and
a pane-owned close of the last tab no longer arms the panel focus reclaim (strip and shortcut
closes still do).

* test: re-register moved pane-ownership sites with the extraction

The retained overlay stack moved into WorkspacePaneOverlayLayers and the floating chrome model
became a guest-paint consumer; the structured-pane layering pin and the browser-guest retention
census both name files, so they follow the seam.

* fix(floating-workspace): mount the shared group tree in a flex frame so panes own their height

The tree's nodes size themselves as flex items, and the floating shell mounted
them in a block container, so every group body — and every pane anchored to
one via anchor-size() — measured 0px tall. Wrap the tree and overlay layers in
the same 'absolute inset-0 flex' frame the worktree split surface uses, record
the host contract on the tree, pin the frame in the panel's element-tree test,
and add a real-renderer e2e spec that measures group-body and pane boxes for
the single-group and split layouts (jsdom computes no layout, which is how the
0px regression passed 50k tests).

* fix(runtime): import the floating worktree minting the structured create path calls

floatingWorkspaceToResolvedWorktree called floatingWorkspaceToWorktree without
importing it. The module is @ts-nocheck (mechanically split runtime), so tsc
never saw the missing import and it only failed at call time — the structured
createSupport probe for the floating workspace threw a ReferenceError. Add the
import and a test that runs the real method, which fails on the missing import
where the typechecker cannot.

* fix(browser-pane): resolve floating and folder workspace paths in notebook navigation

navigateBrowserPageToUrl looked the workspace up in allWorktrees(), which has
no row for folder or floating workspaces, so their notebook navigations ran
with an undefined workspace path. Use getKnownWorktreeById, the same resolver
every other consumer was moved to.

* fix(tabs): keep a workspace's tab selection out of the main window's global selection

* refactor(floating-workspace): launch and close panes like every other workspace

* refactor(tab-group): let the surface that mounts tab groups describe its own chrome

* refactor(floating-workspace): render the floating panel through the shared workspace surface

* fix(floating-workspace): resolve the floating directory once on the host, and keep the controls' inset

* test(floating-workspace): pin the unified floating panel against the shared surface

* fix(floating-workspace): read the floating directory's paths through the app environment

The runtime now resolves the floating directory itself, and the headless
runtime has no Electron app, so the Electron path lookup threw there.

* fix(floating-workspace): keep the main window's frame and shortcuts out of the floating panel

The shared layout drew the main window's 4px titlebar band and sidebar seam
inside the floating panel, and the chat-view toggle fired in every workspace
on screen, so one key press flipped the main window's tab and the panel's.

* fix(floating-workspace): close floating tabs without taking over the main window

An unsaved floating note's save prompt made the floating panel the main
window's active worktree, and never appeared on a fresh launch because the
workbench that owns the prompt was not mounted. The prompt now shows a
floating note in the panel, and the workbench mounts while the panel holds
tabs.

Every close path now reads the same captured emptied-workspace reaction, so a
floating pane closing itself, its shell exiting, a group close, and an unsaved
note close all keep the panel's keyboard ownership, as a tab close already did.

* test(tab-group): pin that a dirty editor close carries its emptied reaction

* refactor(tabs): one rule for when a selection moves the main window's selection

Opening a file kept a floating-only exception while every other writer checked
whether the tab's workspace is the active one. Every writer now asks the same
question, so opening a file in a background worktree no longer points the main
window's editor selection into that worktree.

* refactor(floating-workspace): drop what the shared surface left unused

The terminal pane's imperative close handle lost its last consumer when the
floating panel stopped keeping its own pane registry, and the floating and
folder workspaces built the same lineage-free resolved worktree twice.

* test(floating-workspace): type test fixtures without casts and drop the removed pane ref

* fix(floating-workspace): keep panel focus when a close lands after the panel emptied

Saving the last floating note closes it after an awaited write, so the panel
could render empty before the close armed its keyboard reclaim, leaving the
intent armed with nothing left to consume it. The panel now re-checks whenever
the intent arms as well as when its tab count changes.

A pinned tab's confirmation took focus before the close read panel ownership;
the dispatcher now captures it before any prompt.

* refactor(floating-workspace): finish what the shared surface left behind

- The floating panel no longer accepts AI vault session drops: a session
  cannot resume into it, and its window-level drop handler fired beside the
  main window's.
- The last global-selection writers that spelled the active-workspace check
  inline now use the shared rule.
- Remove the save-dialog hook and item counter the old floating body used, and
  correct comments that said the workbench only mounts once a workspace exists.

* fix(floating-workspace): keep a floating leaf detach from changing the main window's tab type

* fix(floating-workspace): give floating chats their workspace identity and directory

* fix(floating-workspace): read one visibility fact for chat attention and auto-ack

* docs(floating-workspace): correct comments the shared surface made stale

* fix(attention): one on-screen rule for every tab's unread marker, main window and floating panel

* fix(agent-session): pin the folder a session launched in; floating chats resume there or refuse

* fix(native-chat): resolve a floating chat's file links, images and skills in its pinned folder

* fix(agent-session): open a floating chat's terminal handoff in its pinned folder

* fix(agent-session): surface refusal text on chat open, require the launch-directory rule, and keep bookkeeping from gating a launch

* fix(floating-workspace): wait for a floating chat's pinned folder and read panel visibility from the store

* test(floating-workspace): assert the toggle event without a cast

* fix(native-chat): show why a chat could not be opened instead of dropping the host's refusal

* fix(floating-workspace): close chat tabs through shared commands

* fix(i18n): remove obsolete floating editor loading key

* fix(floating-workspace): preserve tab ownership on activation and browser close

* fix(floating-workspace): keep terminal activation and focus reclaim in sync

* fix(readme): point translations at tracked media

* fix(floating-workspace): retain split surface with an empty focused group

* docs(tabs): describe workspace-owned activation accurately

* fix(floating-workspace): finish the main merge and name the pin a launch directory

Port what the merge left on main's newer shapes:
- The floating chat's "folder is gone" refusal becomes a typed reason,
  launchFolderMissing, worded like every other start refusal (copy, desktop
  words, five translations). Older clients drop the unknown reason and show
  the code's generic words.
- The PR's launch-folder tests run on main's journal-database record store;
  the attach-level case is a row in the pre-spawn refusal table.
- Tests main added since the PR's base learn the PR's close-queue settling,
  workspace-scoped notebook lookup and tab-stamped chat host.
- Move the store's visible-tab-index reads into the tab table, so the record
  store stays under the line cap with the pin writer in it.
- Drop a duplicate Worktree import the merge left in a @ts-nocheck file.

Rename the persisted field, the status summary field and the renderer slice
from workspacePath to launchDirectory: it records the folder a session first
launched in, not its workspace's path, and it has not shipped yet.

* Fix floating chat visibility, closes, and directory pinning

* fix(i18n): drop the floating save dialog's strings with the dialog

The floating panel now closes through the shared unsaved-changes prompt, so
its own dialog's six strings had no caller and failed the runtime-catalog
check.

* fix(native-chat): pin floating founders and retain pinned editors

* test: align auto-ack fixtures with unified tab visibility

* test: give the forget-status attach fixture a location

Attach now reads params.location to decide whether a new record is a floating founder, so the partial fixture needs the location real attach params always carry.

* test: type the forget-status attach fixture instead of casting it

The cast tripped the changed-code type-assertion gate.

* fix(native-chat): read a floating chat's model defaults in the folder it was created in

The model picker read the floating setting's current folder, so a chat
pinned to another folder could show that folder's project default.

* test(cross-version): give the stub host the record store the catalog read now checks

* test(claude): give main's thinking-display launch stub the store shape the resolver takes
2026-10-06 11:37:18 -07:00
Brennan Benson 5cf3585b78 fix(native-chat): keep a message accepted before a quit or crash as a held card (#24660)
* fix(native-chat): keep a message accepted before a quit or crash as a held card

A send the host accepted while the agent was still starting, and never handed
over, was rejected unseen at quit or at the next open after a crash. The next
open now keeps a person's message (typed, or a launch's first prompt) as a
waiting card at the head of the queue, held until Resume, Send now, Edit or
Delete; quit no longer rejects it. Each submission records its source so a
restart knows which leftovers to keep. A direct send's replay answers from its
own record, never the queued arm. Cards shown without the queue capability
hide Turn off queueing and the steer chord.

* fix(native-chat): keep an unsent message as a held card at every close, not only a restart

The host now keeps a person's message it accepted and never handed over with one
rule wherever it can no longer hand it over: a quit or crash (settled at the next
open) and a close of the chat (tab close, worktree teardown, orchestration stop).

- The hold is card state: a new per-card hold_reason 'kept', published as the
  existing pausedReason, instead of a fake host_instance value. host_instance
  means the owner again, and the pause clause, adoption filter and /clear carry
  special cases are gone. A kept card holds the cards behind it until the
  person sends, edits or deletes it; a send_failed card still does not.
- A card hand-off rejected by a restart or a close returns as a kept card
  (rejectedDraftSettlement), so a person's next message can never release it.
- One hold function, parameterized by cause (hostRestarted / chatClosed),
  replaces the close path's plain rejection.
- The phone shows published cards and per-card holds whatever the queue
  capability says; only queueing a new send stays gated.
- source gains 'dispatch' for the orchestration preamble (still rejected); an
  unknown source is kept as written and never takes the legacy rule.
- Kept cards from earlier settlements stay ahead of a batch's new ones.

* test(native-chat): the dispatch preamble records its source

* fix(native-chat): skip a kept card like a failed one, and make a quit leave the queue as a crash does

- A kept card is held on its own, as a send_failed one is: the queue sends the
  cards behind it, and the "a message ahead needs attention" caption no longer
  appears behind it (desktop and phone).
- Quit disposes the queue's drain together with delivery, so it mints no
  hand-off that only the next process could settle.
- Only a Send the person asked for (origin client) that a restart or close cut
  short returns kept; the queue's own hand-off returns where it stood, under the
  restart's pause, as on main.
- Comments that said only a capable host gets cards or the card actions now say
  the capability gates only queueing a new send.

* refactor(native-chat): one dispose gate in the queue drain's step

* test(native-chat): the downgrade test names the older build's /clear exception

* fix(native-chat): re-check the drain's quit gate right before it appends a hand-off

A drain step already past its first check when quit begins no longer makes a
hand-off. Adds regression tests for that, for Send now on an ordinary card cut
short by a quit, and for the open-time repair deriving kept from the hand-off's
origin; fixes the pause and settlement comments that called a kept card one the
queue never passes.

* fix(native-chat): a card held on its own starts no restart pause for the others

After a second restart a kept (or send_failed) card is another process's, but it
waits for its own Send, so it no longer pauses every other card under
"Queue paused because Orca restarted".

* fix(native-chat): record on a kept send which card holds it, so no trace survives an Edit or Delete

The rejection row of a send the host kept as a card now names that card
(`keptAsQueuedMessageId`), in the same transaction that writes the card, and the
fold publishes it on the submission. The shared projection draws such a send
only as its card: once the card is sent, edited or deleted, neither the send
nor the sending desktop's local copy of it shows.

* chore(native-chat): leave the unused submission schema as main has it

No client parses published submissions with it (they arrive as typed frames,
and the host's history pages carry the field, as the Edit/Delete test reads);
listing the field put the file over its line budget.

* fix(native-chat): retire a kept send's local copy instead of only hiding it

The outbox reconcile and the send disposition drop an entry whose submission
the host rejected as kept as a card, as they already do for a Stop's
withdrawal, so the copy never comes back as "Not sent / Retry" once the
submission falls out of the loaded page. The projection reads the reconciled
outbox, so its separate filter goes.

* test(native-chat): move the queued-message rig's scripted provider into its own fixture

The rig fixture grew past the 300-line limit once main's changes merged in.

* fix(native-chat): hide a kept send by its own record, not by its card still existing

The transcript hid a rejected send while a queued card held it under its id, so the card's Edit or
Delete brought back a "Not sent" row. It now reads the send's own keptAsQueuedMessageId, which the
host records with the rejection, and the live card list is no longer threaded to the transcript or
the delivery notices.

* refactor(native-chat): move a sent message's row writes into their own journal collaborator

The journal store went past its line limit once main's ledger receipt joined this branch's
transaction hook. The submission and dispatch-transition writes, and what commits in their
transaction, now live in JournalSubmissionWriter; the store's methods delegate to it unchanged.

* fix(native-chat): list a kept send's card id in the submission schema

The schema drops keys it does not list, so a reader that kept a parsed submission would lose
keptAsQueuedMessageId and source, both persisted with the row. The submission schema and the
failure fact it shares with item bodies move to their own modules, with room for both fields.

* test(native-chat): match the transcript and outbox hook signatures main and the swap changed

* test(native-chat): import the journal types once in the queue-delivery test

* fix(mobile): a resend the host kept as a card shows no error and returns no text

The host answers a resend of a message it kept as a card with that
message's rejected submission, marked keptAsQueuedMessageId. The phone read
it as any rejection: "Message not sent" and the text back in the composer,
while the card showed the same text. It now answers like a queued send, as
the desktop's send disposition does: the id is spent, no error, and the card
holds the text.

* test(native-chat): pin that quit's first step stops the queue's hand-off

Quit now stops delivery, the queue drain included, at its first step
(stopDelivery), before teardown drains recovery. The drain-step quit test
runs from that step as well as from the flush.

* test(native-chat): give cards their source and store unknown sources as another build would

#25078 made a card's source required, so the tests that insert a card pass the
person's. The hold's unknown-kind and unreadable-source cases now rewrite the
stored row the way a newer build would leave it, instead of casting a type.

* refactor(native-chat): stop delivery and the queue drain in one line at quit

Main's #25159 left the host at its line limit; quit's stop now disposes both in one
expression instead of a block.

* test(native-chat): hold the drain step without reading the call stack

Bun formats a method's stack frame without its class ("at step"), so the quit
test's caller check never matched, the step was never held, and both cases timed
out once CI ran Vitest on Bun (#25840). Only the drain step heals owed queue
bookkeeping, so the hold needs no caller check.
2026-10-06 05:35:24 -07:00
Kelvin AmoabaandNeil 634c43787c Fix premature Claude automation completion and inherited CI failures (#24878)
* fix(agent-status): keep a Claude pane working until owed task wake-ups arrive

Claude wakes the main agent for each background task that ends, after the
task stops running. "Nothing running" was read as done, so automations
closed the terminal before the final turn.

Fixes #23942

* fix(agent-status): keep owed task wake-ups through failed turns, the cap and nested exits

A failed turn's task list now marks a vanished shell owed like a normal turn end does. Past
the cap only an already-announced sub-agent is forgotten. A process exit clears what is owed
only where the server admits it from the pane's owner.

* refactor(agent-status): move pane-scoped cache entry helpers out of listener-state

listener-state.ts passed the 300-line limit once main's and this branch's additions met.

* fix(agent-status): verify Claude task wake-up completion on its execution host

* fix(agent-status): canonicalize Claude background task identifiers

* Preserve out-of-order Claude task notification evidence

* Verify retained released parser in cross-version checkout test

* Reuse watcher directory-cache enumeration without losing fresh keys

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-10-06 01:02:18 -07:00
Brennan Benson 9905765e3b feat(native-chat): open structured chat's wire and stored records to registered agents, behind a negotiated capability (#25159)
* refactor(native-chat): keep the provider resume handle opaque to shared code

Shared structured-chat code parsed each provider's resume handle: Claude's
session id and branch leaf, Codex's thread id, through a 'claude' | 'codex'
union every new agent had to widen. The in-memory handle is now
{ transport, agent, nativeId, providerData? }: shared readers use nativeId,
lease and handle-chain checks compare transport and agent, and only the
Claude adapter reads its leaf (providerData).

Stored and wire forms are unchanged for Claude and Codex. One encoding
module writes their typed shapes and decodes both those and the neutral
shape a new transport uses, which an older build refuses as unreadable
rather than reading as Codex. Key and root strings, which fork seeds,
superseded creations and resume offers persist, stay byte-identical.

The journal's own handle type becomes the journal-row and attach-wire
encoding of the same handle, and the journal identity carries the
neutral handle (null before the provider proves one).

No user-visible change.

* fix(native-chat): derive journal-row provider handles from the journal identity

The journal row converter now takes the identity every caller already holds,
so a row's handle has one obvious constructor. Tests that wrote the in-memory
handle straight into journal rows now build it through that converter, and the
processless Claude fixture names a not-yet-proved handle as null.

* fix(native-chat): refuse a stored provider handle written in both forms

A typed Claude or Codex handle that also carries the neutral form's
transport, agent, native id or provider data named two identities; it
was read as Claude or Codex and the next write dropped the other one.
Such a row now stays unreadable and is set aside untouched.

* refactor(native-chat): route structured agents through registered definitions

The structured-chat shared layer named Claude and Codex in a closed router
type, in option reads, in frame classification, and in renderer label and
validation branches, so adding an agent meant editing shared types. Each
agent now has a definition (capabilities, resting option rules) owned by its
own module; the router is a registry of definition + adapter pairs built at
runtime setup, and shared code reads the definition instead of the name.

No behavior change for Claude or Codex; no wire or stored shape change.

* refactor(native-chat): name the structured agent list once in host types

The host types, conversation commands, provider-session ownership and the
runtime's create path spelled the structured agent list inline; they now use
the one shared type the record and wire already validate against.

* fix(native-chat): narrow the record before reading its agent's option rules

* refactor(native-chat): make the router's registrations the only agent definition lookup

The at-rest option reads looked definitions up in a second, separately populated map, so a
registered definition could route and declare capabilities while the static map decided which
options a chat at rest accepted. The router now answers `definition(agent)` from its
registrations, the at-rest readers take it through the host's adapter, and the built-in
definitions are listed only where the runtime composes the router.

* feat(native-chat): open the structured-chat wire and stored records to registered agents

A host's structured agents are the ones its runtime registered. Records, RPC
params, persisted tabs and the model catalog accept any registered agent instead
of naming Claude and Codex; each agent's definition declares the transport its
handles live in and the variable its account home pins. A new runtime
capability, agent-session.structured.registered-agents.v1, advertises that a
host accepts and lists its agents (agentSession.agents, with each agent's
capability record), and the host withholds any other agent's tabs and restart
offers from clients that do not advertise it.

* test(native-chat): cover registered agents on the wire, in storage and across versions

* refactor(native-chat): let the record store decide which agents' tabs exist

* test(native-chat): declare the pilot test agent's storage

* test(native-chat): read the old build's saved tabs through a parsed shape

* fix(native-chat): act on restart offers only for agents the calling client can show

A paired client too old to show an agent's chat was listed only the offers it could show, but
dismissing or continuing all reached every offer on the host, and a named continuation answered
with the host's whole remaining inventory. The client's audience now goes to the host with every
restart operation: only offers it sees are reserved, dismissed or returned. Without an audience
(this host's own process, or a client that shows every agent) nothing changes.

* test(native-chat): read an agent-registering baseline's storage on its own terms

The registered-agents downgrade test assumed its baseline release predates registered agents: it
expected the saved-tab parser to erase an unknown agent and called the record reader without the
agents list. Once a release with this change becomes the baseline, both break. The expectations now
follow what the baseline host advertises, and an agent-registering baseline is handed its own
Claude and Codex storage.

* refactor(native-chat): derive record-store admission from the runtime's agent registrations

Which agents a stored record may name and which agents the router drives came from two lists in
the runtime, so a newly registered agent could be routed while its records were set aside. One
list of registrations now holds each agent's definition and the factory for its adapter: the
store's admitted agents are derived from it before the store opens, and the adapters are built
from it once it has.

* fix(native-chat): hand the exit drain a promise for every registered agent

* fix(native-chat): let the adoption conflict check read any agent's ownership

Ownership rows name any registered agent since the stored records opened to them; the adoption
check compares by agent, so it takes the same open id. Only Claude and Codex still adopt.

* test(native-chat): use opaque handle in queued rejection fixture

* test(native-chat): share one Codex journal identity in the integration suite

Main grew the suite to the 800-line limit; the opaque-handle import pushed it
over. The two tests built the same identity inline.

* refactor(agent-session): name the handle's adapter state resumeCursor

Rename the neutral provider handle's providerData to resumeCursor before any
row persists the neutral form: it is an adapter-owned resume position (Claude's
transcript leaf), never identity. Claude/Codex stored and wire bytes are
unchanged; their typed shapes never carried the field.

State the stored-form contract (a handle's field set is closed; later per-link
data goes on the chain link, which every build preserves) and pin it with a
record round-trip test. Document that transport records the id space the
native id was minted in, which can differ from the agent's current transport.

* refactor(agent-session): one required agent registry; declarations admit what they claim

A StructuredAgentRegistry, built once from the {definition, adapter}
registrations, is now a required host dependency and the router routes with
it. The adapter interface loses its optional router-only capabilities?() and
definition?(); every reader (options at rest, thread goal, rewind, the
/compact handover) asks the registry. A live session still narrows rewind
through the adapter, and the host combines declared and narrowed in one
helper. The registry refuses a registration that declares compact, a thread
goal or rewind without the adapter method behind it.

/compact is admitted by the declared capability, so an agent that declares
compact:false gets the commandRefused fact instead of a thrown error.

Also: the cut-turn notice names the agent from the catalog, create-support
builds the account home through agentSessionAccountHome, and the turn
status text older clients read names the session's agent instead of
defaulting to Codex (Claude/Codex text unchanged).

* refactor(agent-session): the router applies the declared rewind itself

The router holds the registry, so its rewindSupport answers the owner's
declared rewind narrowed by the adapter, in one place no reader can bypass.
The host-side combining helper is gone; readers ask the adapter they hold.

* test(agent-session): register the agents the merged-in tests now need

The adoption replay test builds its host over this build's registered agents
instead of an adapter definition method, and the stored-form test passes the
stored agents the record guard now requires.

* chore(agent-session): the registry is the one lookup; drop the test-only empty capability record

* fix(agent-session): keep "dismiss all" restart offers dismissed for the desktop

The desktop renderer calls the restart methods as a paired runtime client
without the registered-agents capability, so it was handed a Claude/Codex
audience and its "dismiss all" took the scoped path: no dismissal fence and
no clearing of unwritten teardown witnesses, so a late teardown write could
bring a dismissed offer back.

The audience is now derived against this host's registry: a client that can
show every agent the host registered gets none, and dismisses exactly as
before (one fence for every offer). A client that truly cannot show some
agent still gets a final dismissal for what it sees: each cleared session is
fenced on its own (bounded, superseded by a dismiss-all fence) and only the
witnesses of agents it sees are dropped.

One predicate now answers which agents a client renders, for both tab
projection and restart offers; the Claude capability rule is folded in.

* fix(agent-session): a changed agent definition never hides that agent's chats

A record was readable only if every handle used the transport its agent's
definition declares today, and its account variable was declared by some
registered agent. A later build that drives the same agent over another
protocol, or under a renamed variable, would have set every existing chat of
that agent aside: no tab, no history, no error.

Readability now asks only that the record agree with itself: its agent is
registered, every handle shares one namespace owned by that agent, and the
account variable is a well-formed name. Whether this build can drive it (the
chain's transport is the one the agent speaks, and the account variable is
that agent's own, since it becomes the child's environment) is decided in
performAttach, the one admission every start of every agent passes through,
and refused there as hostUnsupported. A record pinning another agent's
variable is refused the same way rather than passing because some agent
declares it.

* refactor(agent-session): each agent's registration says where it runs and which account it pins

createSupport, create and the model catalog's account read still chose a
location rule and an account resolver by name (Claude, Codex, else none),
so a new agent would have needed a third branch beside the registration
list that already decides storage, routing and publication.

Each runtime registration now carries supportsLocation(location) and
resolveAccountHomePath({ launchEnv, location, purpose, workspacePath }),
and Claude's and Codex's rules move into their entries unchanged: a read
still syncs no home and starts no bridge, a Codex launch still trusts the
workspace first, and WSL locations resolve as before. The list exists
before the host is built, so none of these reads installs the host, and an
agent the list does not hold is answered no without opening the journal.

* test(agent-session): drop a duplicate registry key; keep fences out of the capsule state

A spread input already carries the registry, so the explicit key was
overwritten (TS2783). The session-fence helper now returns only the fence,
not the capsule state it was given.

* fix(agent-session): a scoped dismiss-all persists no per-session fence

The per-session dismissal fence added a forever-persisted capsule field
with an arbitrary cap that served no caller: only the local desktop calls
the restart methods, and on a host whose agents it all shows it gets no
audience and takes the unscoped, fenced dismissal. A scoped dismiss-all
(a caller that cannot show some registered agent) keeps that agent's
offers, clears only its audience's unwritten witnesses, and writes no
fence; a late write from this process is serialized behind it. The field
never left this unreleased branch.

* fix(agent-session): refuse an attach whose agent is not the session's own

The start check judges the record's agent while the router starts the adapter of params.agent, and the attach wire let the two differ. Admission now refuses agent !== provider as requestMalformed, so the agent checked is the agent started. Every host-built attach already sets them equal.

* fix(agent-session): offer to start a chat only when the start would accept it

The restart offer, a failure's retryable flag and the pre-send check asked only whether the adapter runs the chat's location, while the start also refuses a record this build cannot drive; such a chat was offered Resume and its Retry failed forever. One host predicate, hostCanStartRecord (location support and agentDrivesSession), now answers all four; adapterSupportsRecord stays the reader gate, and an undrivable chat's offer is kept, not retired.

* fix(agent-session): the model-catalog probe uses a record's account only if this build drives it

A record-scoped catalog read started the agent's lister under the record's account path whatever variable the record pinned. It now uses that path only when agentDrivesSession holds, and otherwise resolves the account as for a read with no record.

* refactor(agent-session): the record store admits agent ids; comments say where transport is checked

The store only ever asked whether an agent is registered, so its admission list is now the registered ids; the definition is the one home of an agent's transport and account variable. Comments that said the record store checks transport, and one that cited a nonexistent function, now point at agentDrivesSession and the attach admission. The isPersistedAgentSessionRecord(value, agents) call shape and the test fixture the cross-version probe imports are kept.

* docs(agent-session): the record store admits the registered agents' ids

* test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer

Uses an audience production sends (one that cannot show every agent), per review.

* chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge

* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 and this branch both added the import at different lines; the merge kept both.

* Keep saved chats readable without provider registration

* Keep stored-record compatibility checks independent of registration

* Keep saved providers in restart client audiences

* test(native-chat): type reveal fixtures without assertions

* test(wire): expose known agents in structured host fixture

* Supply startability dependency in the new Codex catalog fixture
2026-10-06 00:41:14 -07:00
Brennan Benson c4ea14cd9f Show a tool call you stopped as interrupted, not failed (#25181)
* Move the turn message ordinals and the turn-row revision to the neutral timeline folder

Pure moves so a shared timeline assembler can use them: Codex's message
ordinal counter becomes ProviderTurnMessageOrdinals and Claude's turn-row
revision becomes the provider-neutral agent-journal turn-row revision. Only
names and import paths change.

* Admit one provider event's writes as one transition, and let rows be found again after a restart

- A sink transition is admitted whole or not at all; its steps run back to
  back at their turn in the journal's write queue, and each resolver reads
  the fold with every earlier write landed. A resolver may also say where the
  row belongs (turn scope, provider reference), and the writer always hears
  how the transition landed. A resolved lifecycle batch chooses its
  settlement mutations from the fold at execution.
- New optional row field providerItemRef: the provider's own reference for
  the item a row is, written only where the row's identity cannot spell it
  (Codex keys messages by their place in the turn and renumbers its item ids
  on resume). Set by the creating write, kept by revisions, indexed by the
  journal fold, never read by clients. A downgrade test shows an older host
  and client render such rows unchanged.
- Provider timeline identity schemes (shared legacy arm, Codex) and the join
  index that resolves a provider item to its row from memory or the fold:
  ordinals and request incarnations are read back from the rows, so a
  restart or an evicted entry finds the original row instead of placing a
  new one.

* Recover message ordinals from the journal's highest place, and forget joins read from a replaced epoch

A fresh join index continued a turn's messages at the first free place, so a journal holding only a
later ordinal (an imported or removed earlier row) had its sequence back-filled. The place is now
one past the highest ordinal any row or echoed send holds there, read through a pure scheme reader.
The join caches also drop what they read when the journal's epoch is replaced.

* Spell the subagent thread's message slot without spreading an identity union

* Add a provider timeline grammar and a shared assembler that decides at its turn in the journal

Adapters translate their provider's dialect into a small grammar (turns,
items, streamed text, requests, context facts, session end/reset); one shared
assembler turns it into the journal rows every structured lane writes.

Each event is planned as one sink transition. Which row a write lands on,
whether a replay writes anything, and every change to what the assembler
knows (its ledger) are decided by the transition's resolvers at the event's
turn in the journal's write queue, against the fold as it stands then. A
forecast (the ledger plus admitted events still queued) only answers apply()
at once. So a refused event allocates nothing, a write the journal rejects
leaves no trace in memory, and a restart or evicted cache finds the same rows
again. Text and full snapshots of one provider item share one row and one
lifecycle; reset always flushes text and settles the old session from the
journal; the open-work budget is derived from what is actually open.

Codex migration contracts compare against the existing Codex translator,
including a restart mid-stream and a repeat that outlives the join cache.

* Fix the types and the exhaustive event switch CI reported for the assembler

* Let the journal decide stream lifetimes, request reuse, named sends and background work

A third review found two blockers with the earlier rounds' cause, a remembered interpretation
trusted after the journal moved on:

- A reused request id was judged by its earlier prompt's settled turn before asking which turn the
  new one lands in, so a real approval in a later turn was dropped. The target turn now decides:
  the old turn again is a replay; a different live turn opens the next prompt beside it.
- A text stream checked its row's turn only on its first write, and a turn's end released streams
  by the turn planning expected. Every write now checks the row, a turn's end stops the streams
  whose rows are in it, and turn status reads the journal first, so another writer's Stop wins.

Also: a message boundary drawn by an event the journal held as a replay no longer splits an
anonymous message; a send naming a turn not yet open waits for that turn; the budget charges a
stream's thread and turn strings and the turn caches are byte-bounded; the open turn ends when the
journal shows it settled; a turn's opener is read from the journal's row.

Background work is now Orca's existing background-task row instead of a tool call flagged
`outlivesTurn` (a flag remembered only in memory, so a restart failed the task). A turn's end
never settles that row, so it survives restarts; session end leaves one in flight unverifiable.
Three tests that opened a background tool call with `outlivesTurn` now open a background-task row
and keep their original expectations about which turn the row stays in.

* Type the unbound assembler helper's drain as the void it reports

* Bound the rows kept for a continued anonymous message and the stopped streams

A row kept for the anonymous stream that may continue it, and the marker that a stopped stream's
queued writes write nothing, lived in the live-stream map and were never removed when no stream
followed. They now live in their own bounded maps, so the live map holds open streams only.

* Read a tool call a stop or the session's end cut short as interrupted, not failed

A call still running when its turn was interrupted, when the provider child
was seen to exit, or that the provider cancelled now carries `endedAs:
'interrupted'` beside `state: 'failed'`. Every build that predates the field
keeps reading the call as the failure it always showed; this build reads both
through one shared reader and counts the call apart from failures in the run
header, without the error tint on its partial output.

The shared assembler, the restart settlement and the Codex lane settle a
running call through one rule: only a proven interruption cuts it short, so a
turn the provider completed around an unclosed call, or work the host lost
track of, still reads failed.

* Assert a completed Codex turn leaves its unclosed call plainly failed

* Assert an unverifiable restart settlement leaves its running call plainly failed

* Let a later death proof correct the calls an unverifiable settle closed

A settle with no proof of the child's death closed its running calls as plain failed, so a
proof written afterwards corrected the turn to interrupted but could not find the calls. The
call now keeps endedAs: 'unverifiable' beside its failed state, and the stale settle revises
exactly those calls whose owner the proof names. Older builds still read them as failed.

* Render a cut-short call's output on mobile, with and without proof

* Find the mobile result box by walking to its View, and type the unverified-ending block

* Walk to the mobile result box without a type assertion

* Keep the journal store under its line limit after the main merge

* Drop the provider item reference, join index and identity schemes from the transition PR

Nothing in production reaches the state they defended (an assembler that lost its memory while
its child keeps streaming the same turn), and the stored Codex id was positional. The legacy
identity scheme moves to the assembler PR with its first caller; the Codex scheme and any
persisted reference wait for Codex to move onto the assembler. The Codex ordinal counter goes
back to codex/, since no neutral code imports it.

* Write a resolved settlement in one transaction through enqueueRows

A settlement too large for one row now commits all its rows or none, through the journal's
existing all-or-nothing write, instead of a row-by-row writer. Every row is built before any
commits, so a settlement naming one item twice is refused before anything is written.

* Drop the transition's landing report; keep the turn-row write fire-and-forget

Nothing reads which steps of a transition wrote. A failed step fails the sink, leaving the
steps before it written; the header says so, and tests cover it plus a settlement whose second
row fails inside the transaction. writeAgentJournalTurnRow returns nothing again, as on main.

* Run a transition's steps as a prefix; drop the paced flag and resolved options

A failed step no longer lets the steps after it write: each step checks, at its own turn in the
journal's queue, whether the write handed over just ahead of it completed, using the queue's count
of completed write bodies (a promise would report the failure only after the next step ran). The
sink fails only once every step has had its turn. The item step's `paced` size bypass and the
resolver's replacement `options` are removed; nothing planned uses them.

* Rebuild the timeline assembler on one admission-order state

The assembler kept a second copy of its state (a forecast beside a ledger), hydrated
the open turn from the journal, and recognised replays, all to recover from losing its
memory while the provider child kept streaming. That never happens: one assembler lives
exactly as long as one child, and a new child is a new assembler in a new generation
whose events land after the dead-generation sweep.

- One state, changed only when the sink admits an event (minted keys included), so a
  refused event takes nothing.
- Every journal-dependent choice is made when the write runs, by keyed reads: a turn row
  is written only where none is, a stream checks its row's turn on every write, a running
  snapshot never lands in a settled turn or relights a settled tool, a request takes the
  first incarnation the journal holds no row for.
- Rows are found by spelling their ids (provider-timeline-rows.ts); no join cache.
- The identity scheme (legacy arm only) lives here with its first caller; requests are
  spelled in their acquisition generation, since JSON-RPC ids restart per process.
- Saved history goes in as `input.history` plus ordinary events with the provider's ids,
  into an empty journal; `session.reset` and every replay rule are gone.
- One terminal-body function (`terminalAgentJournalBody`) is shared with the
  dead-generation settlement.
- The test rig's restart now sweeps and starts a new generation, as production does.

* Cover new running work in a turn the sweep ended

* Run a transition's steps in one queued write that loops over them

The steps of one event now share one turn in the journal's write queue: a
loop writes each in its own transaction through the row writer's
synchronous writeRows (split out of enqueueRows) and stops at the first
throw. Prefix semantics and "nothing lands between the steps" now hold by
construction, so the completed-write counter on the queue, the step gate
and the allSettled barrier are gone; the queue is back to main's bytes.

* End a turn another writer settled the way the provider's end does

A person's Stop settled the open turn's row without a word to the assembler.
The assembler then forgot the turn: its running tools and pending prompts were
never settled, the provider's own end and withdrawal were dropped, and the
turn's text streams stayed counted against the open budget for the life of the
process. Text the provider kept streaming afterwards could land as a message
outside the stopped turn.

- The open turn the journal shows settled ends first, as one transition, through
  the same settlement the provider's turn.end plans; its streams stop and their
  keys drop later text until that turn's end or the next turn opens.
- turn.end and request.withdrawn are admitted for a turn or request the journal
  holds; their settlement writes nothing for rows already settled.
- The budget's re-check frees streams whose turn settled.
- A settled tool keeps its terminal body against any differing write.
- Session-end settlement of lost background work uses the journal's own
  lostLiveWorkJournalBody instead of a copy.
- The rig's window elapses before every read, and restart swaps and disposes
  the old assembler.

* Leave a stopped turn's running tools to the agent's own end

When another writer settles the open turn (a person's Stop), the assembler
now only stops that turn's text and cancels its pending prompts. Running tool
calls stay the agent's: a progress update or completion it reports after the
Stop lands as reported, and whatever is still running settles at the agent's
turn end for that turn, the next turn's open, or the session's end.

An agent's end for an earlier turn while a newer one is open no longer clears
the open turn's activity line or ends its anonymous reply. An unnamed end right
after a Stop ends the stopped turn instead of being dropped. The test rig's
restart no longer writes the dead assembler's window text, matching dispose.

* Pin that a stopped turn's running tools hold budget until the agent's end

* Type the stopped turn's tool progress update as a tool body

* List every event the assembler hands to the decision step

The type-aware lint requires an exhaustive switch with no default case.
Also retitle a Stop test to say what it asserts.

* End a running call as its turn's journal row ends

A call still running when its turn ends takes the state of that turn's
row: a row another writer settled first (a person's Stop) stands, so its
calls read interrupted whatever the provider's later end reports. The
no-ending path that settled calls from the Stop row is gone, since a Stop
now leaves running calls to the provider. Adds the two Spanish strings.

* Settle a stopped turn's running call as its turn row ended after a restart too

The restart sweep ended every running call by the death evidence alone, so after
a person's Stop with no proof the child died the call read failed under a turn
that read interrupted. The sweep and the live dead-generation settlement now ask
the same rule the assembler does: a call in a turn already settled ends as that
row ended; only a turn still running leaves its calls to the evidence.

* Keep the dead-generation settlement under the line cap

* refactor(native-chat): drop saved-history adoption from the timeline assembler

The common pattern discards the history a provider replays while loading a
session, so the assembler has no use for an input.history event.

* refactor(native-chat): a pending input is only Orca's send now

Review follow-up to the adoption removal: drop the comment naming the
provider's saved message, and make requestedAt required since every pending
input comes from input.accepted.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): build this stack's journal identities with main's opaque provider handle

Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test
files from this stack still wrote the old shape. Same lines the downstream ACP branch uses.
2026-10-06 00:40:47 -07:00
Neilandkino 1de3aa405f Fix truncated Unicode branch names in base ref search
Preserve complete branch selectors when Git truncates or disambiguates short names, and keep slash-named local branches separate from remote-tracking refs through worktree creation and reuse.

Keep native/SSH namespace boundaries and mixed-version client capability gates intact. Accept Git-valid dotted components and cover collisions with configured and orphan tracking refs.

Adapted from @nishino-tsukasa's #19540. Independently reviewed in seven adversarial rounds; 235 focused tests and 77 affected-Apple-Git tests pass, along with typecheck, quality checks, and the desktop build.

Correct the inherited file-explorer cache-scan expectation while retaining its exact constant-work bound.

Correct the inherited release-parser compatibility assertion to match the pinned test dependency; all 121 cross-version tests pass locally.

Fixes #19515

Co-authored-by: kino <nishinotsukasavirgo@gmail.com>
2026-10-05 23:47:01 -07:00
Brennan Benson eaaae0196f feat(native-chat): record fresh sessions after failed restoration (#25747)
* feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore

A chat whose saved conversation the agent cannot reopen can now continue in a
fresh one: the handle chain records the new conversation as a creation that
replaces the lost one (which, why, and when), keeping every earlier link.

Rows keep a shape older builds read: the stored chain starts at the latest
replacement and carries the earlier links inside it.

* refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row

Older builds only read Claude and Codex records, so the nested stored form
protected rows no replacement can reach while adding a cap mismatch after a
downgrade. Store the chain as held, refuse a replacement in a Claude or Codex
chain until one has a stored shape older builds read, and refuse a supersession
key on a replacement that names no creation in the chain.

* test(native-chat): prove replacement rows survive downgrade and re-upgrade
2026-10-05 23:22:24 -07:00
Brennan BensonandClaude 5f7c8483e9 test(cross-version): load releases that import a package main dropped (#25731)
* test(cross-version): stand in for packages a release declares but main dropped

The newest release (v1.4.221) imports @streamparser/json, which main removed.
The harness runs release source against the current install, and the release's
RPC dispatcher imports its whole method table, so every wire suite failed at
import time. Extraction now gives each package the release declares and the
current tree no longer declares a stand-in in the checkout's own node_modules
that loads but throws, naming the package, on any use.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(cross-version): decide stand-ins by package resolution, not manifest diff

A package gets a stand-in only when release source imports it at runtime,
the current tree does not declare it, and it does not resolve from the
checkout. A package still reachable from the checkout keeps the real install,
and one the current tree declares but cannot resolve stays a loud failure.
Type-only imports, comment prose and scripts in strings are ignored.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 21:16:17 -07:00
Brennan Benson 3a03441580 refactor(native-chat): structured agents declare their capabilities instead of shared code naming Claude and Codex (#25076)
* refactor(native-chat): keep the provider resume handle opaque to shared code

Shared structured-chat code parsed each provider's resume handle: Claude's
session id and branch leaf, Codex's thread id, through a 'claude' | 'codex'
union every new agent had to widen. The in-memory handle is now
{ transport, agent, nativeId, providerData? }: shared readers use nativeId,
lease and handle-chain checks compare transport and agent, and only the
Claude adapter reads its leaf (providerData).

Stored and wire forms are unchanged for Claude and Codex. One encoding
module writes their typed shapes and decodes both those and the neutral
shape a new transport uses, which an older build refuses as unreadable
rather than reading as Codex. Key and root strings, which fork seeds,
superseded creations and resume offers persist, stay byte-identical.

The journal's own handle type becomes the journal-row and attach-wire
encoding of the same handle, and the journal identity carries the
neutral handle (null before the provider proves one).

No user-visible change.

* fix(native-chat): derive journal-row provider handles from the journal identity

The journal row converter now takes the identity every caller already holds,
so a row's handle has one obvious constructor. Tests that wrote the in-memory
handle straight into journal rows now build it through that converter, and the
processless Claude fixture names a not-yet-proved handle as null.

* fix(native-chat): refuse a stored provider handle written in both forms

A typed Claude or Codex handle that also carries the neutral form's
transport, agent, native id or provider data named two identities; it
was read as Claude or Codex and the next write dropped the other one.
Such a row now stays unreadable and is set aside untouched.

* refactor(native-chat): route structured agents through registered definitions

The structured-chat shared layer named Claude and Codex in a closed router
type, in option reads, in frame classification, and in renderer label and
validation branches, so adding an agent meant editing shared types. Each
agent now has a definition (capabilities, resting option rules) owned by its
own module; the router is a registry of definition + adapter pairs built at
runtime setup, and shared code reads the definition instead of the name.

No behavior change for Claude or Codex; no wire or stored shape change.

* refactor(native-chat): name the structured agent list once in host types

The host types, conversation commands, provider-session ownership and the
runtime's create path spelled the structured agent list inline; they now use
the one shared type the record and wire already validate against.

* fix(native-chat): narrow the record before reading its agent's option rules

* refactor(native-chat): make the router's registrations the only agent definition lookup

The at-rest option reads looked definitions up in a second, separately populated map, so a
registered definition could route and declare capabilities while the static map decided which
options a chat at rest accepted. The router now answers `definition(agent)` from its
registrations, the at-rest readers take it through the host's adapter, and the built-in
definitions are listed only where the runtime composes the router.

* test(native-chat): use opaque handle in queued rejection fixture

* test(native-chat): share one Codex journal identity in the integration suite

Main grew the suite to the 800-line limit; the opaque-handle import pushed it
over. The two tests built the same identity inline.

* refactor(agent-session): name the handle's adapter state resumeCursor

Rename the neutral provider handle's providerData to resumeCursor before any
row persists the neutral form: it is an adapter-owned resume position (Claude's
transcript leaf), never identity. Claude/Codex stored and wire bytes are
unchanged; their typed shapes never carried the field.

State the stored-form contract (a handle's field set is closed; later per-link
data goes on the chain link, which every build preserves) and pin it with a
record round-trip test. Document that transport records the id space the
native id was minted in, which can differ from the agent's current transport.

* refactor(agent-session): one required agent registry; declarations admit what they claim

A StructuredAgentRegistry, built once from the {definition, adapter}
registrations, is now a required host dependency and the router routes with
it. The adapter interface loses its optional router-only capabilities?() and
definition?(); every reader (options at rest, thread goal, rewind, the
/compact handover) asks the registry. A live session still narrows rewind
through the adapter, and the host combines declared and narrowed in one
helper. The registry refuses a registration that declares compact, a thread
goal or rewind without the adapter method behind it.

/compact is admitted by the declared capability, so an agent that declares
compact:false gets the commandRefused fact instead of a thrown error.

Also: the cut-turn notice names the agent from the catalog, create-support
builds the account home through agentSessionAccountHome, and the turn
status text older clients read names the session's agent instead of
defaulting to Codex (Claude/Codex text unchanged).

* refactor(agent-session): the router applies the declared rewind itself

The router holds the registry, so its rewindSupport answers the owner's
declared rewind narrowed by the adapter, in one place no reader can bypass.
The host-side combining helper is gone; readers ask the adapter they hold.

* chore(agent-session): the registry is the one lookup; drop the test-only empty capability record

* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 and this branch both added the import at different lines; the merge kept both.
2026-10-05 21:09:26 -07:00
a47e0f5657 fix(feedback): optimize oversized screenshots without losing detail (#23664)
* fix(feedback): shrink oversized screenshots instead of refusing them

Fixes #22776

* fix(feedback): release each shrink canvas and skip the PNG ladder for a JPEG

Two defects in the attach-time shrink ladder:

- Every step allocated a fresh full-size canvas and left it for the garbage
  collector, so a 32-megapixel source could keep six of them alive at once.
  Past the renderer's canvas memory budget Chromium hands back a blank canvas,
  which would upload as a blank screenshot with nothing to notice it. Each
  step's canvas is now released once toBlob has answered.
- A JPEG source ran two full-size PNG encodes first. A PNG of decoded JPEG
  noise is many times larger than the source it has to undercut, so those
  steps could only ever burn time and a large buffer before the first JPEG
  attempt. A lossy source now starts at the JPEG steps.

* fix(feedback): keep the image read queue usable after a settle callback throws

The queue tail is what the next batch awaits. A throw in either settle callback
left it rejected, so every later attach short-circuited into "Could not read the
attached images" without reading anything — and the rejection went unhandled.

* feat(feedback): say when an attachment is still being prepared

Shrinking a full-screen capture takes long enough that the gap between picking
the file and its thumbnail appearing reads as a dropped attachment: the only
other signal was the Send button quietly staying disabled. The attachment hint
now reads "Preparing attachments…" while a batch is in flight.

* fix(feedback): report an undecodable oversized image as invalid, not too large

* fix(feedback): detect an animated PNG by walking its chunks, not a 64 KB text scan

* docs(feedback): stop claiming toBlob encodes off the renderer thread

* refactor(feedback): seed the image read queue ref directly

* test(feedback): cover the real decode, downscale and release path of the shrink

* perf(feedback): skip image batches still queued when the dialog unmounts

* fix(feedback): show the preparing hint only once a read outlasts a short delay

* test(feedback): pin the animated-PNG walk on corrupt lengths and a refused image's budget

* fix(feedback): blame the shared budget, not the file size, when a shrinkable image has no room

* fix(feedback): cap each shrunk screenshot at half the attachment budget

A shrink kept the largest step that fit everything left, so the first
full-screen screenshot took a 3.8 MB PNG, the second fell to a barely
readable JPEG and the third was refused. Re-encodes now target at most
2 MB; an image that already fits is still attached untouched. The
"larger than 4.0 MB" refusal now applies whenever the capped target an
empty budget would give was the one that refused it.

* test(feedback): pin the half-budget shrink cap independently of its constant

* fix(feedback): reserve a shrinking screenshot's capped size, not its file size, for the paste gate

A 6 MB screenshot still shrinking reserved 6 MB, more than the whole budget,
so a second paste during the shrink fell through to the textarea even though
it attached. Each pending file now reserves what the reader can commit under
the same fit and shrink-target rules. Also corrects the minimum shrink target
comment to the measured smallest step.

* docs(feedback): correct the canvas-release and shrink-queue test comments; mock toast.info in the dialog test

* Revert "refactor(feedback): seed the image read queue ref directly"

This reverts commit e2272b825e.

* fix(feedback): keep preparing hint compatible with current main

* fix(feedback): preserve mixed paste text and seed read queue on attach

* fix(feedback): preserve screenshot detail and pending paste text

Optimize oversized attachments only as full-resolution lossless PNG; refuse them with existing warnings when that cannot fit. Bound each pending batch independently so earlier optimization slack, rejected files, or removed drafts cannot under-reserve bytes and swallow co-pasted text. Regression tests demonstrate the old mixed-batch text loss.

* test(journal): retain provider handle import across main integration

* test(startup): account for encoded Windows prompt lines

* test(compatibility): retain the released journal parser dependency

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-10-05 18:38:06 -07:00
Brennan Benson 9def4b9ba1 feat(native-chat): publish where each submission was sent and the turn a stopped one was answered into (#25073)
* feat(native-chat): publish each submission's journal positions

A client never learned where in the journal a send was accepted or taken back,
so drawing a stopped send had to guess from its own row and from clocks, and the
guesses misfired after restarts. Each projected submission now carries
`submittedSequence` (its submission row) and `resolvedSequence` (the dispatch row
that resolved it; absent while pending): two optional fields, computed on every
fold from rows already stored, never stored themselves. Older clients ignore
them. The failure-fact schema moves to its own module to keep the journal schema
file within its size limit.

* test(native-chat): pin a submission's resolved position on the provider-echo accept path

Also say which rows can resolve a submission, and that the submission schema, not the shared type, omits acceptedSequence.

* refactor(native-chat): publish only where each submission was sent

A rejected send's own row now moves to its rejection (#24710), so the row that
resolved a send is the item's own position and needs no second copy. Keep
submittedSequence, the one journal-order record of where a send was sent.

* feat(native-chat): publish the turn a withdrawn Codex send was answered into

A send Codex answered into a turn that then ended without taking it is
rejected by that turn's end. The rejection row now names that turn's
record, and the submission carries it as answeredInTurnItemId, so a
client can tell which turn the send belonged to without guessing from
journal order or clocks. Absent on every other send.

* feat(native-chat): say how a withdrawn Codex send joined the turn it names

The answered turn becomes { turnItemId, via }: 'start' when Codex
answered the send's turn/start with that turn, 'steer' when Orca steered
it into that running turn. A client can then tell the turn's opener from
a send steered into it even when the opener is on an older page.

* feat(native-chat): state on every rejection whether it names a turn

A rejection a host writes now always carries answeredInTurn: the turn
Codex answered the send into, or null for none. A reader can then tell
'answered into no turn' from a row written before the field existed,
which keeps no answer and is placed by the older rules.

* refactor(native-chat): give the answered-turn schema its own module

Keeps the journal schema file within its line limit.

* docs(native-chat): say an unreadable answered turn reads as none
2026-10-05 15:56:55 -07:00
Brennan Benson 7208be6902 test(native-chat): cover paired runtime launch compatibility (#25072)
* test(native-chat): cover paired runtime launch compatibility

* test(native-chat): use production prompt response fingerprints

* test(native-chat): pass journal items to the paired-runtime outbox hook

Main made journalItems a required outbox input; mirror production by
passing the read state's items.

* test(native-chat): pin paired-runtime routing and capability questions

- Move the paired-runtime chat test into its own file and render the real
  session hook against a paired server that advertises today's capabilities
  while this machine advertises none, so Stop, prompt cancel, repeated Stop
  and question answers fail if their capability question goes to the wrong
  runtime. Add a local and a paired session sharing one id.
- Cross-version: state that the file pins the capability strings a
  v1.4.219 server really advertises, use the desktop's real handshake list,
  and keep the current server only as the control for the refusal.
- agent-launch-routing: give the released-server case a capable client so
  it is refused by the server gate, not the client one.

* test(native-chat): route the paired launch suite and cover the outline read

- Start the cross-version job when the launch route or the client list the
  desktop sends a paired host changes, since the paired launch suite runs
  them.
- Check the rail's conversation outline is read from the paired server, and
  narrow the hook test's header to what it covers.
2026-10-05 14:48:52 -07:00
Brennan Benson e817b0e237 refactor(native-chat): keep the provider resume handle opaque to shared code (#24991)
* refactor(native-chat): keep the provider resume handle opaque to shared code

Shared structured-chat code parsed each provider's resume handle: Claude's
session id and branch leaf, Codex's thread id, through a 'claude' | 'codex'
union every new agent had to widen. The in-memory handle is now
{ transport, agent, nativeId, providerData? }: shared readers use nativeId,
lease and handle-chain checks compare transport and agent, and only the
Claude adapter reads its leaf (providerData).

Stored and wire forms are unchanged for Claude and Codex. One encoding
module writes their typed shapes and decodes both those and the neutral
shape a new transport uses, which an older build refuses as unreadable
rather than reading as Codex. Key and root strings, which fork seeds,
superseded creations and resume offers persist, stay byte-identical.

The journal's own handle type becomes the journal-row and attach-wire
encoding of the same handle, and the journal identity carries the
neutral handle (null before the provider proves one).

No user-visible change.

* fix(native-chat): derive journal-row provider handles from the journal identity

The journal row converter now takes the identity every caller already holds,
so a row's handle has one obvious constructor. Tests that wrote the in-memory
handle straight into journal rows now build it through that converter, and the
processless Claude fixture names a not-yet-proved handle as null.

* fix(native-chat): refuse a stored provider handle written in both forms

A typed Claude or Codex handle that also carries the neutral form's
transport, agent, native id or provider data named two identities; it
was read as Claude or Codex and the next write dropped the other one.
Such a row now stays unreadable and is set aside untouched.

* test(native-chat): use opaque handle in queued rejection fixture

* test(native-chat): share one Codex journal identity in the integration suite

Main grew the suite to the 800-line limit; the opaque-handle import pushed it
over. The two tests built the same identity inline.

* refactor(agent-session): name the handle's adapter state resumeCursor

Rename the neutral provider handle's providerData to resumeCursor before any
row persists the neutral form: it is an adapter-owned resume position (Claude's
transcript leaf), never identity. Claude/Codex stored and wire bytes are
unchanged; their typed shapes never carried the field.

State the stored-form contract (a handle's field set is closed; later per-link
data goes on the chain link, which every build preserves) and pin it with a
record round-trip test. Document that transport records the id space the
native id was minted in, which can differ from the agent's current transport.
2026-10-05 14:48:12 -07:00
Brennan Benson 98171a8934 fix(native-chat): never delete chat history on read; a chat Orca can't load says why once (#24576)
* fix(native-chat): an older Orca keeps a chat with newer content read-only, and an unreadable annotation costs only itself

A body or lifecycle mutation of a kind this build does not know, inside a row of a known kind,
now latches the chat read-only with every row kept, as a newer row kind or version already does.
It used to read as damage, and the open deleted the journal from that row on.

Each optional field of a body schema now declares what an unparseable value means:
- droppable (an annotation): removed from the row in memory; the chat stays writable;
- must-understand (a prompt's questions, a plan approval's plan, a turn's lifecycle, a goal
  change): the row is unreadable and the chat latches read-only.
Only a required field that fails is damage, repaired as before. A test holds every optional
field reachable from a body schema to a policy; the known body kinds are read off the schema.

Prompt options and questions no longer reject a key this build does not know, which deleted the
journal from that row on. The context-usage drop is now one case of the droppable rule.

* feat(native-chat): a chat an older Orca keeps read-only says so before a send is refused

A chat a newer Orca wrote opens read-only, and until now nothing said so: the person found out
when a send failed with "Chats were saved by a newer Orca". The host now names the reason on
every whole history page it serves (`page.readOnly: 'written-by-newer-orca'`: hydration, history
and catch-up resets), the shared client reducer keeps it from the latest whole page, and the
desktop status strip and the phone's composer area say "This chat was saved by a newer Orca, so
it's read-only here. Update Orca to continue this chat."

Every read-only latch the host has is a newer Orca's (its database, a row version, a row kind, a
body kind or must-understand fact), so the reason is derived from the journal's latch, not stored.
The field is optional: older clients ignore it (cross-version test against the newest release's
reducer), and older hosts never send it, so a new client against one says nothing, as today. A
reason this client does not know shows nothing rather than words that may be wrong.

The window-merge helpers move out of the client reducer into their own module.

* fix(native-chat): only a newer build's value goes read-only; damage repairs as before

An older Orca keeps a chat read-only only on evidence a newer build wrote it: a value outside a
closed set this build knows (a body kind, a nested discriminant or literal, a mutation kind), read
off zod's issues. That evidence wins over damage beside it. Anything else that fails (a wrong type,
a missing field, a bad optional value) is damage, repaired as before, so damage no longer freezes
a chat behind an "update Orca" it cannot fix. Drops the per-field droppable/must-understand
policy. A lifecycle mutation's kind is decided before its item id, so a newer kind without one is
kept. The context-usage drop is unchanged from main.

* fix(native-chat): a read-only chat locks its composer and says why there

Removes the separate read-only line (desktop status strip, phone notice component). The host's
`readOnly` on whole pages now feeds the existing composer lock instead: desktop disables the
composer with "Saved by a newer Orca. Update Orca to continue this chat." as its placeholder, and
the phone adds a 'read-only' input-lock reason with the same words. A read-only chat shows no
prompt card it would refuse; the draft stays in the composer. The phone's send-failure line is
back exactly as on main.

* fix(native-chat): a read-only chat pages back through its history

A read-only journal answered every history request with a reset, so a chat this PR now keeps
read-only more often could show only its newest page. Backward reads now serve the snapshot the
open folded, as the first page already does; a forward read of rows still resets.

* test(native-chat): an unknown key in a question entry is read and kept

* test(mobile): the read-only overlay test typechecks

* chore(native-chat): satisfy the changed-code gate in admission and the read-only overlay test

* fix(native-chat): a new value inside a known block or goal arm reads as a newer build's

The open fallback arm of the block and goal unions now aborts, so zod reports the known arm's own
failure instead of only the fallback's; a future closed set there reads unreadable, damage there
still repairs. The schema headers name the context-usage exception and say a new open-string
value must be safe for every older build (rewind keeps only known states; pages carry no row
version).

* fix(native-chat): a read-only chat reconnects for a whole page, so an updated host unlocks it

The client keeps `readOnly` until a whole page replaces it, but reconnected at its cursor and got
only batches, so a desktop attached to a host updated in place stayed locked with "Update Orca".
While it holds the latch it now subscribes without a cursor; the snapshot re-derives the state on
any host version. The phone already subscribes without one.

* fix(native-chat): every desktop write control follows the one read-only fact

The structured session's transport state derives one fact from the page's `readOnly`: the words
for why the host refuses writes. Every write control reads it. While it holds, the chat has no
turn and no work, as the host projects it, so the working row and Stop go away; a pending prompt
stays shown above the composer with its card disabled; queued cards' Send now, Delete and Edit,
the queue's Resume, the goal banner's actions, the model and option pickers, dictation and
background-task Stop are disabled or withheld. The composer placeholder stays the one place the
reason is said. `lockReason` now locks the composer by itself.

* fix(mobile): every phone write control follows the one read-only fact

The phone session derives one fact from the page's `readOnly`, the words for why the host refuses
writes, and every write control reads it: no turn or work while it holds, so no Stop; the pending
prompt stays shown with its card disabled; queued cards and Resume are inert; the model and option
pickers stay shut; the composer field is not editable, so its placeholder keeps saying why, and the
lock applies at once instead of after the transport lock's settle.

* test(native-chat): the newest release hydrates a read-only chat and scrolls back through it

* test(native-chat): a read-only chat reads idle even with a send its provider never answered

* test(native-chat): composer lock test without type assertions

* fix(native-chat): round-3 read-only fixes

- An old /model or option request no longer reopens its picker when a read-only chat unlocks: the
  menu is keyed by the request alone, and a lock only keeps it from mounting open.
- A disabled question card still steps through its questions; only answering is locked.
- A failed send's notice in a read-only chat says only that the message was not sent, with no
  Retry: the composer already says why, and no retry can land. Its wiring moves into
  useStructuredAgentSessionDeliveryNotices.
- The host's background-task stop flags stand; the client no longer overrides them.
- The view reads the read-only fact through one local flag.
- The schema header says what an older build's rewind does with each open string.

* test(native-chat): count picker mounts without an effect

* fix(native-chat): read-only keeps other failures' words and shuts open option menus

- In a read-only chat, only a send the newer-Orca refusal stopped is shortened to "Your message
  was not sent."; any other saved failure keeps its own words, and none offers a Retry.
- A model or option menu open when the lock lands has its items disabled, so it cannot send a
  change the host would refuse.

* test(native-chat): lock-lands picker test uses a typed surface

* fix(native-chat): a locked composer draws its reason

The editor's placeholder used the default that draws nothing while the editor is not editable, so
a read-only chat's composer was empty and its reason lived only in the aria-label. The placeholder
now draws while disabled, and its words are empty unless the composer is locked with a reason, so
every other disabled composer (a pending prompt, no terminal) looks as before.

* fix(native-chat): sync the locked-placeholder flag in an effect, not during render

* fix(native-chat): a read-only chat is not offered for resume or counted as failed to resume

After a relaunch, a chat whose journal this build keeps read-only (a newer Orca's) was listed in
"Resume interrupted chats?", failed with "Orca couldn't resume this chat. Open it to continue
manually.", and left a status-bar "N chats failed to resume" that never cleared, since nothing can
continue a chat this build cannot write. The resume set now skips such a chat after the checks
that end an offer (fork, moved on), so the offer is kept, not spent, and an updated Orca offers it
again; the failure ledger keeps an already-filed failure for it but does not show it. Both read
the chat's open journal, which listing and acting open first.

* fix(native-chat): a refused resume continuation keeps the refusal's reason

A send refused while continuing a chat after a restart was filed with its code alone, so a newer
Orca's refusal lost its `journalWrittenByNewerOrca` reason. The continuation now carries the whole
refusal, as a refusal from the agent's start already did, so the filed failure keeps it.

* fix(native-chat): a newer Orca's chat keeps its resume offer however it is met

- A newer Orca's whole database counts as read-only for resume, so a chat whose journal cannot
  be opened at all (a table the newer schema changed) is not offered, run or counted either.
- The checks made right before sending no longer skip read-only chats: turning one away there was
  read as the user having moved on and deleted the offer. Its send is refused instead.
- Settling a resume files nothing for a newer Orca's refusal; the existing rollback reopens the
  offer for an updated Orca.
- The continuation records the refusal as a reference, without the wire prose.

* chore(native-chat): one import of the session wire types in the continuation

* docs(native-chat): resume and read-only comments match the current rule

* test(native-chat): a closed set of a journal row cannot change without the row version

The test walks the body schema and the row and mutation kind tables, collects every closed set (a
row kind, a mutation kind, each enum, literal or discriminant), and compares them with a snapshot
recorded beside the row version. A new value makes older builds go read-only, so it fails until
the reader ships first or `v` is bumped and the snapshot updated. Tags a catch-all arm accepts as
any string (block types, goal states) are listed apart: older builds read a new one as-is.

* feat(native-chat): a body or plan subject of a newer kind is kept and the chat stays writable

An item body kind and an approval subject kind this build does not know now read through the
same catch-all blocks and goal states use (`openDiscriminatedUnion`), instead of making the chat
read-only. The item is kept, drawn by nobody, and ignored by everything that reads items (turns,
prompts, status); a rewind carries it as it was, kept by its place like every other row. An
approval whose subject this build cannot draw shows its `detail`, which Orca's writers fill with
the subject's text. A sent message of a kind this build does not know stays a newer build's.

* fix(native-chat): rewind narrows a retained body by its kind before normalizing it

* fix(native-chat): one chat that cannot open no longer stops the startup restore of the rest

* fix(native-chat): a damaged chat fails to load and nothing is deleted

A row this build cannot parse, a gap, or an epoch without its first row used to
be repaired on open: every row from the damage on was deleted and a rebuild from
the provider transcript was attempted. The open now fails through one refusal
(failLoadOnJournalDamage, journalCorrupt), every row stays, and a damaged
per-chat file is kept whole and never copied. A newer build's row still wins
and keeps the chat read-only. The body classifier for closed-set values, the
repair marker and disclosure, and journal recovery from the transcript go.

* chore(native-chat): no reset or adapter left on the open's fixtures

* feat(native-chat): an approval of a newer Orca's subject kind can only be cancelled

Desktop and phone show its detail and the line "This request needs a newer
version of Orca.", disable every answer, and keep the card's cancel, which ends
the turn. The chat stays writable.

* test(native-chat): the closed-set guard says a missed version bump fails older builds' load

* test(native-chat): the closed-set guard names what a missed version bump does

* fix(native-chat): a row the reader rejects is never written, and a chat that cannot load keeps its tab

Every journal insert reads its serialized row back with the reader inside the
write's transaction and refuses one it rejects, so Orca's own writer can no
longer leave a chat that fails to load. A restored chat whose open fails keeps
its tab and says why when opened. An epoch named with no rows is founded afresh
again, deleting nothing. The at-rest test now expects a damaged chat to be
refused; the replay gate keeps its 2026-09-11 evidence as it was run.

* fix(native-chat): a newer Orca's approval subject is carried as it was, and its card cancels with or without a turn

The approval subject's type is open, so code that reads a plan narrows first; the
host's prompt bounding carries an unknown subject instead of rebuilding it as a
plan, which threw in every start's stale settlement. A Codex rewind keeps a
newer Orca's item in its place. A whitespace-only tag is damage. The card's
cancel reaches the host without a running turn on hosts that take that. A
provider row the reader rejects ends its turn as interrupted and the next send
works. A guard fails if an approval subject kind is added before clients can be
gated (STA-9262).

* fix(native-chat): a card's cancel sends nothing without a turn again, and rewind keeps a newer row after any held row

The host's cancel request has to name a turn on every version, so the turnless
prompt cancel is reverted on desktop and phone. The rewind merge moves its anchor
for every row the provider still holds, so a newer Orca's row stays after one
whose kind this build does not know.

* fix(native-chat): a card this build cannot answer leaves the composer open, and a send starts a turn

An approval of a subject kind this build cannot draw, raised by a newer host's background agent
while no turn runs, left nothing to press: its answers are disabled, its cancel needs a turn, and
the desktop composer gave the card its slot. A send the host queues waits behind any pending
prompt, so even the phone's open composer only queued.

While every pending prompt is one this build cannot answer, the desktop composer stays open, and
desktop and phone send without asking to queue: the send starts a turn, and the card's cancel then
settles it. A chat a dead run left is unaffected: opening it already cancels those prompts.

* feat(native-chat): a chat a newer Orca saved fails to load and says to update, with nothing to send into

Opening a chat a newer Orca saved (a row kind, batch-change kind or sent-message kind this build does
not know, a newer row version, or a newer history store) used to open it read-only: the history
shown, the composer locked with a reason, every control greyed. Now it fails the load, like a
damaged chat, with its own words: "This chat was saved by a newer Orca. Update Orca to open it."
Every row is kept. The read is final, so the pane stops retrying; reopening the tab reads again.

A chat whose load failed for good (damaged, or a newer Orca's) offers no composer under the error,
on desktop and on the phone, so the failure is said once and no send can be refused a second time.

A restart offer for a newer Orca's chat is spent without a word: no failure filed, nothing counted.

Removed with the read-only mode: the journal's read-only latch and every check of it, the history
page's readOnly field (never released), the cursorless reconnect, read-only paging, the composer
lock and placeholder, the greyed controls, and the resume exclusion.

* test(native-chat): a newer Orca's records open no chat, and a status fake carries its submissions

* test(native-chat): the status fake's cast says what the feed reads

* fix(native-chat): a chat's read that failed for good takes the whole pane, over a loaded transcript too

* test(native-chat): the final-read-failure test names its reasons

* fix(native-chat): a newer Orca's chat keeps its restart offer, and its refused load is logged once

* refactor(native-chat): the newer-Orca chats a restart listing skips live beside the candidate reader

* fix(native-chat): a refused chat load keeps where it failed, so the log names it and a new reason logs again

* chore(native-chat): the reducer's window merge back where main has it, and comments say a newer Orca's chat fails to load

* test(agent-hooks): the rename test's journal fakes carry submissions, as a journal snapshot does

* test(agent-hooks): the rename test's journal fakes say what the status feed reads

* fix(native-chat): Dismiss all ends the restart offers this Orca lists, and keeps a newer Orca's hidden ones

* test(native-chat): a resend against a newer Orca's store answers unknown, and the phone says nothing beside the read's words

This build never opens a chat a newer Orca's database holds, so a resent send id there cannot be
answered from its journal: it answers "outcome unknown", never a refusal and never a made-up
record, with no second delivery and no write. On the phone, a send error left beside a read that
failed for good is not shown; the pane keeps only the read's words.
2026-10-05 10:45:58 -07:00
Brennan Benson 1dc6157614 ci: run the cross-version tests when the chat send builders or the RPC request path change (#25061)
* ci: run cross-version wire suites when agent sends or orchestration change

The selector skipped the cross-version wire job for #24901, which changed the
shared agent-send path, the structured send envelope builders, and orchestration
RPC code. Route the send payload/fingerprint builders, src/main/runtime/orchestration/,
and the orchestration RPC methods to the job, and pin each rule in a test.

* ci: run cross-version wire suites only for code they execute

The agent-session suites now build their send through the shared outbox
builder and check it against the release host's schema and fingerprint,
so the send builder and outbox are selected exactly. Load-only
orchestration rules are dropped; the dispatcher-path files every suite
request runs through are selected instead.

* ci: run the release's own send admission, cover queued sends and the RPC reply builder

* test(cross-version): a wrong send fingerprint must be refused, so an agreed one is not vacuous
2026-10-03 22:23:01 -07:00
Neilandnwparker 136b990a05 fix(search): negotiate supported agents across mixed host versions (#25009)
Keep older request and reply parsers usable while current peers retain all supported history.

Co-authored-by: nwparker <nwparker@users.noreply.github.com>
2026-10-03 22:03:11 -07:00
Neil 62451920ed fix(git): preserve SSH review context and worktree ownership (#24945)
* fix(git): preserve SSH arguments and guard background writes

* test(terminal): settle fish fixture startup readiness

* fix(git): preserve bare UNC SSH paths

* fix(git): unescape shell operators in Windows SSH paths

* test(runtime): settle removal writes before fixture cleanup

* test(git): skip optional OpenSSH probe when unavailable

* perf(git): skip equal-tip reads and bound relay discovery

* test(processes): ratchet the removed relay Git spawn

* test(shells): wait for initial zsh output before sending input

* Fix SSH review context and recover Git maintenance cleanup safely

* Keep relay Git compatibility fixtures outside shared client projects

* Fence superseded maintenance and preserve mixed-version session search

* test: model Git child termination and search catalogs

* test: retain catalog authority over history flags
2026-10-03 05:24:35 -07:00
aca2d51e0e fix(jcode): harden Windows hooks and negotiate remote history (#24998)
Redirect the managed Windows payload file into curl instead of starting
pipeline shells, register native Windows delivery coverage, and document
Jcode v0.89.0+ as the upstream launcher requirement for invisible hooks.

Negotiate Jcode history in both directions with mixed-version Orca hosts,
preserving supported search filters and old-client response compatibility.

Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
Co-authored-by: JianJia2018 <39438074+JianJia2018@users.noreply.github.com>
2026-10-03 04:27:17 -07:00
NeilandOrca Integration Recovery 843607b1bc Register supervised Qoder China and Qwen Code (#24616)
* Add Qoder session history and search with real CLI coverage

* Allow the real Qoder marker file to end with a newline

* Keep Qoder tool output out of history previews and search

* Keep Qoder search pages readable by older clients

* Verify persisted Qoder history after a real generated and resumed task

* Negotiate Qoder filters before searching an older execution host

* Combine search client imports for the CI plugin gate

* Keep the relay search oracle aligned with legacy agent filtering

* Register supervised Qoder China and Qwen lifecycle integration

* Cover Qoder China mobile assets and mixed-host resume gates

* Verify Qoder provider tags against the older released wire parser

* Verify China and Qwen keep independent Windows hook scripts

* Verify Qoder registrations against the installed older Windows release

* test(qoder): align search capability contracts and pin old-host fencing

* fix(qoder): rank exact picker identities and command aliases first

* test(qoder): preserve the regional CLI shared icon expectation

Keep the full bundled-asset and no-remote-image checks, with an explicit
shared-logo basename for Qoder China. The map also works with older
catalog type unions.

* fix(qoder): align China catalog entry with fallback order

---------

Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example>
2026-10-02 22:13:26 -07:00
Neil f97ca2a49d Add Qoder session history and search (#24614)
* Add Qoder session history and search with real CLI coverage

* Allow the real Qoder marker file to end with a newline

* Keep Qoder tool output out of history previews and search

* Keep Qoder search pages readable by older clients

* Verify persisted Qoder history after a real generated and resumed task

* Negotiate Qoder filters before searching an older execution host

* Combine search client imports for the CI plugin gate

* Keep the relay search oracle aligned with legacy agent filtering

* test(qoder): align search capability contracts and pin old-host fencing
2026-10-02 17:23:11 -07:00
Brennan Benson e2c5414f76 fix(native-chat): an older Orca keeps a chat with a newer row kind read-only instead of deleting the rest of its history (#24477)
* fix(native-chat): an older Orca skips and keeps a journal row of a kind it does not know

* test(native-chat): a newer build's journal row kind survives reads, writes, rewinds and reopens

* fix(native-chat): an older Orca keeps an unknown journal row kind read-only unless its writer declared it skippable

A row of a kind this build does not know, in a well-formed envelope, now latches the chat
read-only with every row kept, the same way a newer row version does. It is read past only
when its writer declared `ifUnknown` on the row: `skip` (a rewind drops it) or `carry` (a
rewind carries it after the rebuilt history, epoch, seq and fence restamped). Every existing
kind changes queue or turn state, so skipping by default would let an older build write from
a wrong fold.

- journal-row-kind-compatibility.ts: each kind states how older builds read it, typed over
  every row kind, so a new kind cannot be added without a declaration.
- Rewind restates the Resume and Stop as before, then carries `carry` rows in source order;
  the restatement goes back to { lifted, liveStop }.
- Replay treats a row whose body names another sequence than its stored key as malformed at
  the key, so the next write never collides with it; catch-up reads stop there too.

* refactor(native-chat): drop the writer opt-in; an unknown journal row kind only latches read-only

An older Orca now treats a row of a kind it does not know exactly like a row from a newer
schema version: every row stays on disk and the chat opens read-only until an update. The
writer-declared skip/carry opt-in, its in-memory placeholder, the carry through rewinds and
the per-kind registry are removed: no current or planned kind could use them, and they can
come with the first kind that may safely be read past.

Kept: an unknown kind needs the envelope every row keeps (epoch, sequence, fence, timestamp),
else it is damage as before; a row whose body names another sequence than its stored key is
malformed at the key; the epoch row's validator names its kind. The schema header states the
rule for adding a kind: keep the envelope, and either ship the reader first or bump `v`.

* refactor(native-chat): derive the journal's known row kinds from the row union

Each kind's own-field check now lives in one table keyed by every kind JournalRow holds, and
the set of kinds this build knows is derived from that table. A kind added to the union without
a check fails to compile, rather than latching this build's own chats read-only as a newer
build's kind. A test reads one valid row of every kind.
2026-10-01 23:49:14 -07:00
Neil 026b8378a4 test(wire): make release compatibility probes deterministic (#24538) 2026-10-01 23:03:08 -07:00
Brennan Benson 444f1952c7 ci: run every cross-version wire test, picked up by folder so new ones can't be skipped (#24499)
* ci(cross-version-wire): run the whole directory so no compatibility test is left out

Three cross-version tests ran in no CI job because the job named its files by hand.
Run the directory instead, ratchet that every file kept out of the unit shards
runs in some PR job, and re-run the job when the modules the newly running
tests guard change.

* test(cross-version): give the orchestration downgrade test its siblings' 120 s budget

* ci(unit-exclusion): count only merge-gating jobs, and require each excluded file's job to fire on it

The coverage check counted any pr.yml job, including e2e, terminal IME and Windows WSL, which are
left out of verify.needs and so cannot block a merge. It now reads verify.needs and the reusable
workflows those jobs call.

It also only proved that some step names each excluded file, not that the job runs when the file
changes. The structured-session zsh login-shell test runs only in shell_contracts, whose path
trigger matched neither it, its harness nor its subject, so a PR touching only those ran it
nowhere. The check now asserts a change to each excluded file fires a gating job that names it,
and the shell trigger gains those three paths.

* ci(cross-version-wire): trigger on the turn-outcome vocabulary and the schema version-skew resolver

A change confined to src/shared/agent-turn-outcome (the arms a newer host publishes) or to
orchestration-schema-version-skew (how current code reopens a downgraded database) skipped the
job whose tests guard exactly those contracts. Also corrects the publish/read direction in the
turn-end comment.

* test(cross-version): state why the orchestration downgrade test needs 120 s

* test(ci): glob the unit tree once for the unit-exclusion coverage checks
2026-10-01 20:53:51 -07:00
Brennan Benson 757736628f fix(native-chat): a paired server admits structured chat by client capability, not its own chat setting (#24203)
* fix(native-chat): a host admits structured sessions by client capability, not its own chat setting

A host's experimentalStructuredNativeChat decided whether any paired client could reach
agentSession.* at all, and whether session.tabs.* showed it structured tabs. That setting is the
host user's own launch preference: whether a new agent opens as a chat or a terminal is decided by
whoever launches it. Using it as admission control meant a client whose own preference was
"structured chat" was refused on a host whose preference was "terminal", and chats opened while
the setting was on were withheld from mobile once it was turned off.

The gate now asks one thing: did the client advertise agent-session.structured.v1 (in-process
callers negotiate nothing and are always admitted). Tab projection and restore follow the same
rule. With the setting no longer gating anything, the separate cleanup gate (close, cancel,
unsubscribe, release), which existed only so those kept working after the setting was switched
off, is identical to the main gate and is folded into it. The settings listener that republished
tabs when the setting changed is removed, since projection no longer depends on it.

The host setting still picks the default for launches that start on the host itself
(agent.launch from mobile, orchestration worker-start).

* fix(native-chat): negotiate client-chosen launch mode so released phones and old servers keep terminals

Hosts advertise agent-session.structured.client-launch-mode.v1: they admit
structured sessions by client capability alone. A remote client that does
not advertise it (phones released before agent.launch) asks createSupport
to pick the launch mode, so the host keeps answering that with its own
setting, exactly as before. Cleanup methods keep their own named gate so a
future admission condition cannot make close or cancel refusable.

* chore(native-chat): justify the two type assertions this change's lines touch

* fix(native-chat): chats that already exist keep showing whatever the chat setting says

The structured chat setting decides only what new agents open as. With it
off, this machine's structured chats used to be hidden while the host,
which no longer reads the setting, still reported them to the workspace
activation gate, so a workspace holding only a chat opened empty. The
local chat mirror and its startup restore now run whatever the setting
says, the continue-after-restart offer follows the chats that exist, and
the setting's copy says it applies to new agents.

* test(native-chat): pin that a host advertises the client-chosen launch mode

* fix(native-chat): mirror this machine's chats only where it holds them

Round 1 ran the local chat mirror for everyone so existing chats show
whatever the setting says. That gave every desktop a permanent
session-tabs listener, which turns on the runtime's phone replication
paths, plus two full session-tab censuses at startup, and made the
browser client mirror its remote host a second time.

The runtime now says whether it holds structured chats: its structured
host is built only when saved chats were restored at startup or a client
created one here, and it announces the moment one is built. The mirror,
the startup restore and the continue-after-restart offer run only when
the setting launches chats or the host holds some, and never in the
browser client. A chat a paired client creates here with the setting off
still appears at once. The chat behaviour settings show wherever chats
exist, and the setting's copy says it picks what new agents open as. The
toggle-off teardown this made dead is removed.

* test(native-chat): record install listeners without a cast

* fix(native-chat): mirror this machine's chats only once it holds one, not once its host is built

Session history, resume preparation, terminal resume commands and replay-safe phone launches all
build the structured host for users who never had a chat, which turned on the chat mirror and the
structured-only settings rows until the next restart. The signal is now derived from the host's
records (or a records file still owed its import) and pushed when the first chat is restored or
created. A throwing listener no longer fails the install that fired it.

* feat(native-chat): createSupport reports the saved selection a new chat on this host starts with

A chat on a paired server starts with the server's saved model and options, which the desktop could
not read, so its picker showed a guess. createSupport's answer, which the desktop already waits for
before a paired launch, now also carries that seed as a new optional field (older clients ignore it).
Create and createSupport read it through one resolver so they cannot drift.

* refactor(protocol): move the Electron remote client capability list into its own module

Merging main left protocol-version.ts one line over the max-lines limit on this branch. The list of
capabilities the desktop advertises to a paired host moves, unchanged, into
electron-remote-runtime-client-capabilities.ts, the module the next PR in the stack already uses
for it; importers point there.

* test(cross-version): stub the launch seed resolver createSupport now reads

* fix(native-chat): the desktop tells its own host it picks each launch mode, so retrying an existing chat works with the setting off

* docs(native-chat): name the real exit for the released-phone createSupport rule

* test(cross-version): a released client still gets the host-setting createSupport answer; a launch-mode client gets supported plus the seed
2026-10-01 18:55:32 -07:00
Brennan Benson ccb63afc06 fix(native-chat): a Stop still reads as yours after Orca restarts, because the turn's end reads the Stop's event (#24311)
* test(native-chat): a Stop over a card sent now into the running turn keeps it paused

Red on main: Codex's turn end withdraws the steered hand-off and the queue
sends the card again as a new host turn, with no pause recorded.

* fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered

A card sent now into the running turn was still pending when Stop judged the
pause, so nothing was recorded; the interrupt then withdrew the hand-off, the
card went back to waiting unpaused, and the queue sent it again as a new turn.
The pause now counts a hand-off that may still return to waiting, judged with
the appended row applied, so a withdrawal lands under the pause and an
acceptance retires it in that same write. Codex and Claude both hit it.

* test(native-chat): the Claude re-send case fails on its diff, inside the test's budget

* test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it

The provider's answer, the provider dying, the chat closing, a restart, a
withdrawal still owed at open, and a /clear (refused until the hand-off ends,
then carrying every waiting card paused 'cleared'); each ends with the queue
sending again.

* fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card

* refactor(native-chat): derive the queue's pause from Stop and Resume journal rows

Stop now appends one journal row where it takes effect, before the interrupt,
whatever the queue holds; Resume appends its own. The pause is a pure function
of the fold: the latest Stop with no later Resume and no later accepted turn a
person asked for. A /clear's carried cards name their source, which is the
replacement's 'cleared' pause. Host-origin turns never lift either.

One predicate decides which cards a pause holds; by default every waiting card
without a hold of its own, including one queued after the Stop. The drain's
consume re-judges it inside its own transaction.

The rows are tombstones of an id no item takes, carrying the mark: a released
host reads an unknown row kind as corruption and truncates the journal there.

Deletes the stored pause (recordPause, the retire hook on every appended row,
the settle-before-record step, mayReturnToWaiting and its row overlay) and the
tests that only proved it retires. The queued_message_pauses table stays in the
schema, unread and unwritten, for downgrade safety.

* fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones

A Stop's pause now holds only the cards queued before its row, plus a steer it
withdrew, which returns to its own place. Each card records the journal
position it was queued at, and the one hold rule compares that with the Stop
row. A card queued after the Stop is a new instruction: it sends as usual, but
the drain still stops at the first held card, so it never overtakes them.
/clear's pause holds the cards it carried. Holding every card again is a
one-line switch in that rule.

* fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction

The drain's pick and its consume now read one function, nextSendableQueuedCard,
so a Stop row that lands between them holds a newer card behind an older held
one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.

* fix(native-chat): stop creating the unused queue pause table

The queue's pause is derived from journal rows, so nothing reads or writes
queued_message_pauses. It was still created on every open "for downgrade
safety", but an older build creates it itself when it opens the database, so
the table only sat empty in every new database. The tests now pin that no
pause table exists.

* fix(native-chat): a Stop's pause never hides the restart pause

A Stop holds only the cards queued before it. The pause derivation still
returned the Stop alone whenever it was in force, so the restart pause was
never considered: a card queued after the Stop, written by a host process
that has since exited, sent by itself after Orca restarted, with no pause
header and no Resume. A /clear pause that held nothing could hide it the
same way.

Every pause in force is now derived. A card is held if any of them holds
it, and it names the first that does. The drain's pick, the consume
transaction's re-check and the published header all read that one rule;
the header names the pause holding the first card Resume would send.

* test(native-chat): pin the Stop's no-resend, lift and held-card rules

- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent
  again" at one instant, before a queue ignoring the pause re-sends. They
  now wait for the stopped turn to end and re-check after a quiet window.
- The deleted-card test read a card queued after the Stop, which sends
  whether or not a person's turn lifts it; it now reads the Stop's pause
  before and after that turn.
- Unit cases pin that a Stop holds a card with no recorded position and one
  queued before a rewind.

* refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller

The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.

* test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event

* test(native-chat): pin that Stop and Resume rows never reach apps or count as history

* test(native-chat): only a person's Stop event pauses the queue

* test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop

Through the real host: the event names the turn and who asked and is in the
journal when the interrupt reaches the agent; at an agent still starting it is
there before the start is ended and holds a card queued before it; an idle Stop
writes one only when it withdrew a send; and the queue's claim re-judges a
pause that landed after its pick.

* test(native-chat): a card held at a starting agent is checked before the Stop's timing

Also says precisely what the claim's in-transaction pause check defends
against: the Stop and the drain share one serialized lane.

* test(native-chat): a released build keeps and folds a journal holding Stop events

Replays this build's rows from the released build's own journal database: every
row is kept, the history after the Stop still folds, and an older client is sent
only removed ids no item uses.

* style(native-chat): format the Stop event changes

* test(native-chat): type the released build's exports through one checked helper

* fix(native-chat): the Stop/Resume row guard narrows to those tombstones only

* test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade

The Stop-event downgrade test ran in no CI lane: unit shards exclude the
cross-version folder, and the cross-version lane runs a fixed file list that
did not name it. It is now on that list.

Its only case replayed the rows into a release's own fresh database, because
that release cannot open the current host database. A second case opens the
journal this build wrote with a main build that shares the database: it opens
writable, keeps every row, appends, and this build then reopens it with the
person's Stop still pausing the queue.

* fix(native-chat): a Stop that stops nothing new writes no Stop event

A Stop reaching a running agent wrote a Stop event on every press. Two
presses before the first interrupt landed wrote two events, so a card
queued between them counted as before the latest Stop and was held,
though a card queued after a Stop should send normally. A Stop naming a
turn that had already ended, as a phone sends late, also wrote an event
for a turn it never stopped.

It now writes one only when it withdrew a queued send, or stops something
no event records yet: not a turn the journal no longer runs, and not the
live turn a Stop still in force already names, unless a card was handed
over into it since, which this Stop's interrupt sends back and must hold.
The interrupt and the "already finished" note are unchanged. A Stop at a
starting agent still always writes.

* test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop

* chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled

* fix(native-chat): any later Stop event ends a person's Stop pause

A person's Stop paused the queue until their next accepted turn or Resume,
and a later Stop of another reason (the host stopping the agent, an
eviction, a close) was ignored. Now the pause is the latest Stop event's:
a later Stop of any reason ends a person's pause, and only a person's Stop
pauses. The fold keeps the latest Stop event whatever its reason.

An eviction of a resting chat writes no Stop event (a Stop that stops
nothing writes nothing), so it cannot release held cards; a test pins that
no event means no lift.

* fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed

A Stop pressed before the agent's turn shows in the journal (before
Claude's echo, or before Codex opens the turn) records no turn. A second
press once the turn showed compared that missing turn with the live one,
wrote a second Stop event, and held a card queued between the presses.

A repeat is now judged by what was sent since the Stop in force: with
nothing sent after it (a refused send aside), a Stop that named no turn,
or named the live one, is repeated and writes nothing. Anything sent since
and not refused, including a send whose fate is unknown, makes the new
press write, since its interrupt may send that card back to waiting.

Tests: the two-press case across the turn showing; a steer between the
presses settled unknown; and a Stop naming a turn that ended while the next
card is sent but shows no turn yet, which writes and holds that card. The
fold test that claimed an eviction path is renamed.

* fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes

A Stop or Resume row's value is read from disk with no shape check, and
the pause fold stored whatever it found. A stored `stopEvent: null` would
then throw on every pause check for that chat: the queue's pick, its
send, and every queue update to clients. No build writes such a row, so
this is hardening.

The fold now reads a Stop only when it is an object with a string reason
and a finite time, and a Resume only when it is `true`. Anything else is
ignored: it pauses nothing and ends nothing. The row is still not treated
as malformed, which could cut the history short.

* fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends

Every stop that ends work now writes the Stop's event before it ends the child: a
person's close of the chat, an eviction (worktree teardown, orchestration stop, tab
cleanup) and the idle sweep's stop of a start that never landed. A stop that ends
nothing writes nothing, and quit writes none: its resume marker records why.

The turn-end write reads the latest Stop event where every turn row is built, so the
adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a
person's Stop or close named, ending with no verdict of its own after that Stop, ends
as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that
found the turn running. When the provider refuses the interrupt and the turn runs on,
a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop
again after a refusal is a new Stop.

* refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event

The cause of a stop was threaded in memory from each entry through the host's stop
step, the adapter router and each adapter's close onto the `ended` it settled with,
and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters
settle a turn they cut as interrupted with no verdict, the host's fallback does the
same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the
journal's latest Stop event to say whether it was a person's.

- `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`.
- Claude reads an error result after a person's Stop as their cancellation from the
  journal's Stop event (through the event sink), not from a per-turn slot, and a
  refused interrupt is the host's refusal row, not `withdrawTurnStop`.
- An owed wind-down keeps no cause: its retry's fallback reads the Stop event.
- The mutation context's Stop passes no cause: its step already wrote the event, and
  the delivery loop's child-end reason is read back from it.
- A Stop pressed before its turn showed applies to the turn that opens under it,
  unless a send a person made since was accepted.

* test(native-chat): a turn a later send opened is no Stop's that named no turn

* test(native-chat): the restart test's death proof carries its detail

* refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names

The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to
a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that
stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and
the Stop names that turn, so the turn running instead never reads as the person's by its id alone.

* fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next

A Stop that named no turn read as the person's cancellation for every later turn that opened
after it, until a send a person made was accepted. The queue's drain, orchestration mail and a
restart continuation send as the host, so a turn they opened long after, cut by a crash, read
"Interrupted" as if the person had stopped it. The Stop now applies only to the first turn
opened after it.

* fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted

Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The
translator judged whether a person's Stop explained it by its own copy of the Stop rule, which
ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed".
The translator now writes such an end as interrupted with no verdict and no error row whenever a
person's Stop may name the turn, and the journal's one rule decides as it writes the end.

* fix(native-chat): a person's Stop and /clear each name why they end the agent

The host's mutation path ended the agent with one "recorded" ending for every caller, which read
back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop
event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop
`user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat.

* fix(native-chat): a host stop judges whether it ends work after the provider's rows land

A close, eviction or host stop decided whether it ended a running turn from the journal as it
stood, while the provider's own rows (the turn its echo opened) could still be in the session's
event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It
now reads after the sink drains, as a person's Stop does, through the same check; a drain that
fails or takes over a second reads working.

* fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts

A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed,
Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended
turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.".
A Stop that ends the provider's session ends whatever is in flight, so its event now names the
live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only
the turn the Stop names.

The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at
its line budget.

* fix(native-chat): the idle sweep reads working by the same rule as a stop's event

The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the
stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event,
which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed
work now reads the main agent working the way every session list and the event writer do.

* test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain

A host stop now drains the session's sink once to judge whether it ends work, so the tests that
fail the eviction's drain-published step skip that first drain.

* fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes

The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose
admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule
returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop
event, whatever send it retires: a person's Stop pause holds through it.

* fix(native-chat): stopping a start that carries no send writes no Stop event

A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried.
A start with a send already reads working, so the clause only mattered for a start with none,
which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle
clock, which is why the idle sweep had been changed to close the conversation in the same pass.
The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before.

* test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event

The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs
and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop
pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns,
restart as a process that dies with no close, which like a quit writes no Stop event, and assert
that both the Stop's and the restart's pauses are in force first.

* fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason

An eviction or host stop that landed while a person's Stop or close was already ending the same
turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of
that turn read as news. A host reason now writes nothing while a person's Stop still decides what
runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's
own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does,
so the mail turn after it is not the turnless Stop's.

* fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop

The translator left an error result that names no reason to the journal's Stop rule whenever a
person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn
opened next. So a real error on a later turn read "Failed" with its error text dropped. The
translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core
`turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree.

* fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it

A person's Stop pressed while the agent starts names no turn, and the send it stopped is
cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration
mail, a restart continuation, the queue's drain, all of which send as the host), so a host
eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A
Stop that named no turn now binds only a turn no send journaled after it opened: any send since,
of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex
accepts it, instead of opening the stopped send's own turn first.

* test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind

A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new
sequence, so by sequence alone it would bind the next turn opened after the rewind. A send
journaled after the restated row voids that binding (the previous commit), which this pins.

* fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row

After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but
the relaunch still added the error row saying the provider stopped mid-response, which a live Stop
never writes. The settle now skips that row when every turn it interrupts is the person's Stop's
by the journal's one rule; a crash nobody stopped keeps it.

* test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn

* fix(native-chat): a host stop whose sink drain fails reads the journal as it stands

A host stop drains the session's sink before judging whether it ends work, and a failed or slow
drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing,
which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is
now best effort: the stop goes ahead either way and only its record is at stake, so a failed or
slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule.

* fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened

A person's Stop pressed before any turn showed names no turn. It bound the first turn opened
after it, then (5ead1f6bcc) any turn opened by no later send, so a turn the host started for
a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host
eviction of it wrote no Stop event when its send had been abandoned by the close first.

The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it
stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's
row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal
aliases to the submission), and a handed-over send's item sits at its handover, so the target set
is derived from the journal. A card the Stop held is handed over after it, so it is no target; a
Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a
restated Stop binds no turn opened after it. With no turn running, a host stop defers to the
person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks
before its echo row lands, passes the send its echo acknowledged.

* fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal

A drain past its bound may still hold the turn's row, while the echo's acceptance has already
landed, so the journal as it stands read nothing running: a person's close of that turn wrote no
Stop event and the turn read as news. The two drain outcomes now differ: one that failed has
nothing more to deliver, so the journal's read holds (as before); one still running reads working.

* test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's

The branch had no test. An eviction with only the stopped send unanswered writes nothing; one
with a send made after the Stop still unanswered writes its event.

* fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due

The previous commit read every drain past its bound as working, so a host eviction or stop of an
agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's
Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened
no turn the journal holds, the race it was for; otherwise the journal's read holds.

* test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one

With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule
that deferred when any one was would pass the old control.

* fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop

A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or
a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last
send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered
into a running turn, whose item carries that turn's scope, are skipped.

* test(native-chat): type the Stop test envelope's fields narrowly

* test(native-chat): the retry of a close whose exit was unproven writes no second Stop event

The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its
event stands alone and the child's end keeps the cause, for a person's close and an eviction.
2026-10-01 16:56:13 -07:00
Brennan Benson 976dc00337 fix(native-chat): Stop's pause is worked out from the chat's history, so a steered message is never re-sent (#24072)
* test(native-chat): a Stop over a card sent now into the running turn keeps it paused

Red on main: Codex's turn end withdraws the steered hand-off and the queue
sends the card again as a new host turn, with no pause recorded.

* fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered

A card sent now into the running turn was still pending when Stop judged the
pause, so nothing was recorded; the interrupt then withdrew the hand-off, the
card went back to waiting unpaused, and the queue sent it again as a new turn.
The pause now counts a hand-off that may still return to waiting, judged with
the appended row applied, so a withdrawal lands under the pause and an
acceptance retires it in that same write. Codex and Claude both hit it.

* test(native-chat): the Claude re-send case fails on its diff, inside the test's budget

* test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it

The provider's answer, the provider dying, the chat closing, a restart, a
withdrawal still owed at open, and a /clear (refused until the hand-off ends,
then carrying every waiting card paused 'cleared'); each ends with the queue
sending again.

* fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card

* refactor(native-chat): derive the queue's pause from Stop and Resume journal rows

Stop now appends one journal row where it takes effect, before the interrupt,
whatever the queue holds; Resume appends its own. The pause is a pure function
of the fold: the latest Stop with no later Resume and no later accepted turn a
person asked for. A /clear's carried cards name their source, which is the
replacement's 'cleared' pause. Host-origin turns never lift either.

One predicate decides which cards a pause holds; by default every waiting card
without a hold of its own, including one queued after the Stop. The drain's
consume re-judges it inside its own transaction.

The rows are tombstones of an id no item takes, carrying the mark: a released
host reads an unknown row kind as corruption and truncates the journal there.

Deletes the stored pause (recordPause, the retire hook on every appended row,
the settle-before-record step, mayReturnToWaiting and its row overlay) and the
tests that only proved it retires. The queued_message_pauses table stays in the
schema, unread and unwritten, for downgrade safety.

* fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones

A Stop's pause now holds only the cards queued before its row, plus a steer it
withdrew, which returns to its own place. Each card records the journal
position it was queued at, and the one hold rule compares that with the Stop
row. A card queued after the Stop is a new instruction: it sends as usual, but
the drain still stops at the first held card, so it never overtakes them.
/clear's pause holds the cards it carried. Holding every card again is a
one-line switch in that rule.

* fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction

The drain's pick and its consume now read one function, nextSendableQueuedCard,
so a Stop row that lands between them holds a newer card behind an older held
one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.

* fix(native-chat): stop creating the unused queue pause table

The queue's pause is derived from journal rows, so nothing reads or writes
queued_message_pauses. It was still created on every open "for downgrade
safety", but an older build creates it itself when it opens the database, so
the table only sat empty in every new database. The tests now pin that no
pause table exists.

* fix(native-chat): a Stop's pause never hides the restart pause

A Stop holds only the cards queued before it. The pause derivation still
returned the Stop alone whenever it was in force, so the restart pause was
never considered: a card queued after the Stop, written by a host process
that has since exited, sent by itself after Orca restarted, with no pause
header and no Resume. A /clear pause that held nothing could hide it the
same way.

Every pause in force is now derived. A card is held if any of them holds
it, and it names the first that does. The drain's pick, the consume
transaction's re-check and the published header all read that one rule;
the header names the pause holding the first card Resume would send.

* test(native-chat): pin the Stop's no-resend, lift and held-card rules

- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent
  again" at one instant, before a queue ignoring the pause re-sends. They
  now wait for the stopped turn to end and re-check after a quiet window.
- The deleted-card test read a card queued after the Stop, which sends
  whether or not a person's turn lifts it; it now reads the Stop's pause
  before and after that turn.
- Unit cases pin that a Stop holds a card with no recorded position and one
  queued before a rewind.

* refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller

The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.

* test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event

* test(native-chat): pin that Stop and Resume rows never reach apps or count as history

* test(native-chat): only a person's Stop event pauses the queue

* test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop

Through the real host: the event names the turn and who asked and is in the
journal when the interrupt reaches the agent; at an agent still starting it is
there before the start is ended and holds a card queued before it; an idle Stop
writes one only when it withdrew a send; and the queue's claim re-judges a
pause that landed after its pick.

* test(native-chat): a card held at a starting agent is checked before the Stop's timing

Also says precisely what the claim's in-transaction pause check defends
against: the Stop and the drain share one serialized lane.

* test(native-chat): a released build keeps and folds a journal holding Stop events

Replays this build's rows from the released build's own journal database: every
row is kept, the history after the Stop still folds, and an older client is sent
only removed ids no item uses.

* style(native-chat): format the Stop event changes

* test(native-chat): type the released build's exports through one checked helper

* fix(native-chat): the Stop/Resume row guard narrows to those tombstones only

* test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade

The Stop-event downgrade test ran in no CI lane: unit shards exclude the
cross-version folder, and the cross-version lane runs a fixed file list that
did not name it. It is now on that list.

Its only case replayed the rows into a release's own fresh database, because
that release cannot open the current host database. A second case opens the
journal this build wrote with a main build that shares the database: it opens
writable, keeps every row, appends, and this build then reopens it with the
person's Stop still pausing the queue.

* fix(native-chat): a Stop that stops nothing new writes no Stop event

A Stop reaching a running agent wrote a Stop event on every press. Two
presses before the first interrupt landed wrote two events, so a card
queued between them counted as before the latest Stop and was held,
though a card queued after a Stop should send normally. A Stop naming a
turn that had already ended, as a phone sends late, also wrote an event
for a turn it never stopped.

It now writes one only when it withdrew a queued send, or stops something
no event records yet: not a turn the journal no longer runs, and not the
live turn a Stop still in force already names, unless a card was handed
over into it since, which this Stop's interrupt sends back and must hold.
The interrupt and the "already finished" note are unchanged. A Stop at a
starting agent still always writes.

* test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop

* chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled

* fix(native-chat): any later Stop event ends a person's Stop pause

A person's Stop paused the queue until their next accepted turn or Resume,
and a later Stop of another reason (the host stopping the agent, an
eviction, a close) was ignored. Now the pause is the latest Stop event's:
a later Stop of any reason ends a person's pause, and only a person's Stop
pauses. The fold keeps the latest Stop event whatever its reason.

An eviction of a resting chat writes no Stop event (a Stop that stops
nothing writes nothing), so it cannot release held cards; a test pins that
no event means no lift.

* fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed

A Stop pressed before the agent's turn shows in the journal (before
Claude's echo, or before Codex opens the turn) records no turn. A second
press once the turn showed compared that missing turn with the live one,
wrote a second Stop event, and held a card queued between the presses.

A repeat is now judged by what was sent since the Stop in force: with
nothing sent after it (a refused send aside), a Stop that named no turn,
or named the live one, is repeated and writes nothing. Anything sent since
and not refused, including a send whose fate is unknown, makes the new
press write, since its interrupt may send that card back to waiting.

Tests: the two-press case across the turn showing; a steer between the
presses settled unknown; and a Stop naming a turn that ended while the next
card is sent but shows no turn yet, which writes and holds that card. The
fold test that claimed an eviction path is renamed.

* fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes

A Stop or Resume row's value is read from disk with no shape check, and
the pause fold stored whatever it found. A stored `stopEvent: null` would
then throw on every pause check for that chat: the queue's pick, its
send, and every queue update to clients. No build writes such a row, so
this is hardening.

The fold now reads a Stop only when it is an object with a string reason
and a finite time, and a Resume only when it is `true`. Anything else is
ignored: it pauses nothing and ends nothing. The row is still not treated
as malformed, which could cut the history short.

* refactor(native-chat): one reading of a Stop's turn for its event and its note

A Stop's event and its note each worked out the same two facts on their own:
which turn the Stop is about (the one it named, else the one running), and
whether a named turn is the one the journal shows running. The event decides
before the interrupt; the note and whether the session ends decide after the
provider's answer, so those decisions stay separate, but the facts they read
are now one helper each in structured-agent-session-turn-stop-notes.ts:
structuredAgentSessionStoppedTurnId and
structuredAgentSessionStopNamesTurnNotLive. The event's turn, the note's key,
the session-ending condition, the running-command check and the repeat check
all read them. No behavior change.

Tests: a Stop naming no turn records the running turn on its event, and
rewrites that turn's note as a Stop naming it does.

* refactor(native-chat): a failed-interrupt Stop reads its turn through the shared helper

The new branch that ends a Codex child after a failed interrupt asked
whether the Stop's turn still runs with `turnId ?? liveTurnId`, a third
copy of "the turn a Stop is about". It now reads
structuredAgentSessionStoppedTurnId, the value the note key already uses,
read at the same point before the cancel. No behavior change.

Test: a Codex Stop whose interrupt failed ends the child, holds the card
queued before it with the queue paused, and writes its Stop event before
the turn's end.
2026-10-01 13:21:58 -07:00
Brennan Benson c6cfcc034e refactor(native-chat): structured chat failures always reach the diagnostics log (#24312)
* refactor(native-chat): give the structured chat host one required logger

The structured chat runtime took an optional onError callback that the
desktop never passed, so a late dispatch settlement, an unanswered-dispatch
release, a journal event-sink write and a provider lifecycle delivery that
failed were dropped with no trace. Other host failures went to scattered
console.warn calls, which reach nothing in a packaged desktop build.

The runtime and host now take one required logger (warn/error with a scope
and fields). The production logger writes each entry as a failed span to
<userData>/logs/main.trace.ndjson, which the diagnostic bundle collects, and
to the console (stderr under a supervised headless host). The runtime and the
host wrap it so a logger that throws never fails what it reports, and the
install refuses without one. Sites that deliberately kept a recovery-capsule
error out of the log still log no error object.

* refactor(native-chat): hand the chat host's collaborators the logger, and give orcad its trace file

The delivery loop, idle sweep, queued-message drain, lease renewer, event
sink, conversation map and provider start/exit settlement each took an
internal error callback that the host mapped onto the logger. They now take
the logger itself and log under their own scope. The event sink keeps one
onFailed hook, which decides whether to stop the provider, not whether to
report. The dead-generation settlement returns its failure so each caller
logs it under its own scope.

orcad now installs the desktop's local trace sink under its own data root, so
a headless host's chat failures reach <data-root>/logs/main.trace.ndjson as
well as stderr.

Also passes the logger in the test fixtures the first commit missed, which
tc:node caught.

* fix(native-chat): keep repeated chat failures from flooding the trace file, and record their causes

- The production structured-chat logger writes a repeated failure (same level, scope, session,
  message and error text) once per 5 minutes, carrying how many repeats it swallowed; the
  tracked set is capped at 256.
- Trace entries now carry the error's code (and SQLite errcode) and up to three causes by name and
  message.
- A chat read whose conversation will not open is logged through the host's logger
  (open-for-read), and so are the runtime's chat-tab bookkeeping failures that already hold the
  host.
- orcad writes its own orcad.trace.ndjson, closes it after every quit handler, and flushes it on
  process exit; a trace file that cannot be opened leaves tracing off instead of stopping the app
  or orcad.
- Tests: the desktop wiring test proves the logger reaches the trace sink, and the privacy tests
  read every level the logger received.

* fix(native-chat): log a created chat's tab-publication and launch-prompt failures through the host's logger

* fix(native-chat): key a repeated chat failure on everything its entry writes

The repeat suppression keyed on the message and the error's text, so two refusals with the same
code but different causes, a plain error and a refusal of one code, or two object-valued errors
shared a key and the second was swallowed for five minutes. The key is now the entry's whole
written content (fields, code, errcode, refusal reason, cause chain, a stable rendering of a
non-error value) plus the error's name and message; a refusal's reason is also written.

* test(native-chat): pin that an error's name keeps two repeated failures apart

* test(native-chat): build the refusal in the repeat-key test as the wire does

* fix(native-chat): read an error's code and a refusal's reason by narrowing, not Reflect.get
2026-10-01 11:21:50 -07:00
OrcaWinandm4air 2a83c9536f ci(daemon): gate PRs on daemon protocol crossing from the newest release (#24089)
Lands daemon-protocol-facts.mjs from the Windows update diagnostic branch with a
stricter parser, and adds check-daemon-protocol-crossing.mjs (rule R1): the working
tree must attach the newest release tag's daemon. Rollback crossing is reported only.
Runs in the cross-version-wire job, which already has full tags; tag selection moves
to config/scripts/stable-release-tags.mjs so both use one rule.

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-30 23:23:28 -07:00
Brennan Benson 0b79720c2e feat(native-chat): the chat strip and the sidebar read the host's child records (#22614)
* feat(native-chat): publish the host's child records to the status summary and the chat strip

The status summary and the background-task channel now read a session's child
records from the host's canonical store, through the status sink its row
landed in, and derive the legacy task and subagent shapes from the same views.
The parent row folds its child-work liveness from those records at ingest,
not from the summary's task list. The adapters no longer push their task DTO
to clients: the onBackgroundTasksChanged path is gone, and a child-work ingest
is what republishes both the summary and the strip. Finished children stay
listed until the session's own next turn starts. A reader that predates child
views never receives a roster whose rows are all settled.

* feat(native-chat): the chat strip reads the host's child records with its parent's verdict

The strip's roster now renders from the child views its channel carries, and
passes the verdict the session's own status row gives its children, built the
way the sidebar builds it (the row's freshness and the status feed's
observation). So one child reads the same in the strip and the sidebar, live,
after the transport drops, and once the row goes stale. A roster of finished
children stays shown until the next turn but no longer animates the monitoring
indicator or blocks conversation commands.

Tests: an end-to-end run on a host with no renderer (a real hook server as the
status sink) shows the summary and the strip channel carrying the same records
at every step, the parent row folded from them, retention, and an older
client's task list holding live work only; a wired renderer test shows both
surfaces agree when live, lost and stale.

* test(native-chat): a newer host's view degrades, an older reader keeps its live roster, a finished roster holds nothing open

- The view decoder ignores unknown keys, degrades unknown kinds, states,
  outcomes and memberships, and drops only rows it cannot identify.
- At the RPC boundary a reader that predates child views gets no strip for a
  roster of finished children and never the views themselves; a stop-only
  reader keeps rows whose host offers no targeted stop.
- The strip shows finished children without reading them as live work.
- The row keeps its child list's identity when a summary repeats it.

* refactor(native-chat): the summary's task list is the legacy projection's live rows, unfiltered

* test(native-chat): type the switch tests' mocks instead of asserting them

* test: remote clients advertise reading child views

* docs(agent-status): the structured row folds the store's child records

* fix(agent-status): keep the view reader's header from reading as a value import to the renderer boundary

The renderer node-builtin boundary test scans raw text, so a header comment
that said "imports" ahead of the import block turned the type-only import of
agent-status-child-work into a value edge that reaches node:crypto.

* refactor(native-chat): the status summary's broadcast equality gets its own module

The status feed crossed the file-size limit once the summary gained the main agent's turn
outcome beside the child views. Which summary changes reach every session list now lives in
structured-agent-session-status-summary-equality.ts.

* fix(native-chat): command admission reads the strip's child records

A conversation command was refused on the provider tracker's own roster
while the strip read the host's child records, so a drift between the two
rule sets could refuse /clear with a stop instruction the strip had no
button for. Admission now reads the same records through the same read as
the strip, uses the strip's liveness fold, and asks for a stop only when
the strip renders one. The adapter contract no longer exposes the tracker
roster, so no host decision can read it.

Also records when the legacy child shapes die, every earlier death of a
settled child, and the display-precision invariant behind the summary's
clock tolerance.

* refactor(native-chat): command admission takes only what it reads of a turn

* fix(native-chat): the session list drops a session's children when the store does

A session's end no longer removes its child records: a child still running
settles with an outcome nobody reported, and a finished one stays listed.
Records now leave only at the session's own next turn, at the cap on settled
records, or when the host lets go of the session and its row leaves the store.

The summary kept after the host lets go used to strip its children on close,
a rule of its own. It now re-reads them from the store when the row leaves,
through the same read every live summary uses, so the session list and the
chat strip list the same children at each step, including a forget with no
close. Closing only revokes ownership, as before the child records existed.

* test(native-chat): write the Codex frame script's parent row out step by step

Once every surface reads the child records, the provider tracker's roster is
no oracle: it and the records read the same child executions, so agreeing
with it cannot catch a defect in either. Each frame now states the child
liveness and the parent row it must fold to.

* fix(native-chat): the idle sweep and the restart snapshot read the host's child records

The idle sweep (keep an agent running while its subagents or commands run) and
the restart-resume snapshot (what a chat was doing when Orca stopped it) both
read the provider tracker's roster through the adapter interface, which no
longer carries it. Both now take the host's one child-record read, the same one
the status summary, the chat strip and command admission use.

The snapshot's working test also folded that roster through the shared fold's
old `backgroundTasks` input, which the fold no longer reads, so a settled lead
whose subagent was still running would have been offered nothing. It now hands
the fold the records.

* test(native-chat): the child-record tests follow the merged command lifecycle

A command is a live child record from its start and is removed, not settled,
when it stops, whatever Codex tagged it. The end-to-end switch now shows the
child's `npm test` as a live row beside its dev server, and both are gone once
they exit; only the finished subagent stays listed until the next turn. Letting
go of the session is its tab closing, since a closed conversation whose tab
remains keeps its row.

Command admission's finished row is a subagent, the one kind that settles, and
the failed-verdict row test admits its live subagent as a host record, the only
thing the row folds.

* refactor(native-chat): the status feed's journal projection cache gets its own module

The status feed crossed the file-size limit once the child records joined the
agent-start signal and the completion feed's status read. The per-journal
projection, cached per commit, now lives in
structured-agent-session-status-journal-projection.ts.

* test(native-chat): the admission test's compaction resolves with a real outcome

Main's compaction result is a tagged outcome; the host-level admission test
resolved its mock compaction with an empty object.

* feat(native-chat): the sidebar lists running subagents; the strip, running then the newest finished

The host keeps every child record; what each surface lists is picked from them on every read, so
nothing is stored twice. The status summary, which every session list reads, now carries only
running children (and a finished one whose shell still runs, which reads monitoring): a finished
or failed subagent leaves the sidebar and stays in the chat's strip. The strip lists every running
child, then the newest finished ones, 100 rows in all; more than 100 running all show.

This matches common practice: sidebars show live subagents, and finished ones stay in the chat's
panel, newest first. No wire field is added. An older client reads fewer rows: its legacy task
lists were already live-only in the summary, and the strip's settled tasks come from the same
bounded roster.

* fix(sidebar): one rule for what the worktree sidebar lists: running children, from every source

`worktreeSidebarListsChild` is the one definition: a child that runs, counting a finished one
whose own shell still runs (it reads monitoring). The sidebar's row builder applies it to every
child source it reads, a terminal agent's hook roster and a chat session's records alike, and the
host's status summary applies the same predicate, so the sidebar's payload stays small. The chat's
strip keeps finished children, newest first.

A terminal agent's hook roster already drops a child on its own stop, so nothing changes there:
a teammate between turns and a child gone quiet still run, and still show. The selection module
moves to `agent-child-work-listing.ts`, since it now covers every source, not only chat sessions.

* test(native-chat): the switch test passes the startup child key main's status bar takes

* fix(native-chat): a finished child stays until the user's next send, not a turn Claude opens on its own

Claude wakes the agent on its own when a background task ends, and that wake is a
new root turn. Keying retention on the newest root turn retired every finished
child about two seconds after a background agent or shell finished, so its
outcome never showed in the strip.

Retention now keys on the user's newest send the provider accepted (a message, a
steer or a command), with the journal epoch so a rewind still retires. A turn the
provider opens itself and a subagent's turn carry no send. Replayed captured wake
orders through the real adapter, hook server and status feed.

* fix(native-chat): a background Stop reaches the tasks the child records show

The strip draws a row's Stop, and /clear, /compact and rewind wait for background
work, from the host's child records, but the Claude adapter still resolved which
tasks a Stop reached from its own tracker's roster, and refused to stop at all
once that roster was empty. A task the records kept live after the roster dropped
it showed a Stop that sent nothing and blocked those commands until the chat tab
closed.

The host now resolves the provider ids a Stop sends from the records (the same
per-row rule the strip and admission use; every such row for stop-all), and the
adapter stops exactly those, with no tracker guard. An acknowledged stop ends the
record: a running task sends its own stopped frame first, and the CLI answers
success with no frame for a task it no longer knows. A refused stop leaves the
record live. No production code reads the tracker's roster any more.

* fix(native-chat): one rule for a finished child that still owns live work, at any depth

The listing kept a finished child whose work ran through any depth of ownership,
but retention at the user's next turn protected only the direct owner, so a
finished agent whose finished subagent still ran a shell was removed and that
subagent jumped to the top level. Both now read settledOwnersOfLiveWork.

* fix(native-chat): an older client sees a Codex child's shell as it did before views

Clients that predate child views read a flat task roster derived from the views.
It listed a Codex child agent's shell as an extra row beside the running agent,
then as a bare command once the agent finished. The derivation now hides a
running agent's commands and names a finished agent's as "<agent> — <command>",
as the Codex tracker did; the label rule moves to a shared module both use.

* fix(native-chat): the chat decodes a roster's child rows once, as the frame arrives

The client reducer compared raw wire rows, so a row shaped by a newer host could
throw there, and the strip decoded a new array on every render, which defeated its
grouped-rows memo while a turn streamed. Rows are now decoded where the frame
enters the reducer, an unchanged roster keeps its identity, and the strip's parent
context is rebuilt only when one of its values changes.

* fix(native-chat): the strip channel forgets a closed conversation's roster

It kept the last roster fingerprint of every conversation for the host's lifetime.
The conversation map now tells observers when one leaves it. Also corrects the
summary's children comment: it carries running children only.

* docs(native-chat): rewrap the retention comment

* fix(native-chat): a task's own ending replaces a Stop's, and a child finished after the user wrote stays

Two lifecycle gaps from the round-1 fixes.

A Stop acknowledged ahead of the task's own ending relabelled it. The SDK hands
Orca a control answer as soon as it reads it and queues other frames, so when a
task finished just as the user pressed Stop, the acknowledgement arrived before
the task's completion the CLI wrote first, and the task read "Stopped" with its
result lost. An acknowledged Stop now ends a record provisionally (outcome basis
`stop-acknowledged`); the task's own terminal frame replaces it, and nothing
replaces an ending the task reported itself.

A finished child still vanished with no new action from the user when the send
the provider took was written before the child finished: a steer Claude takes at
its next boundary, or a queued draft handed over at the end of the turn. The
user's next turn now carries when they acted (the send's written time, or the
draft's queued time, both on the host clock), and only children that finished at
or before it retire; a later one stays until the user's next send. A rewind
still retires every finished child.

Also: a Stop-all keeps stopping the remaining tasks after one request fails, then
reports the failure.

* fix(native-chat): the strip keeps one empty list for a roster that omits one

A roster with only running or only finished rows made a new empty array on every
render, so the strip regrouped its rows each time. One shared empty list keeps
its memo.

* fix(native-chat): a strip row whose owner the 100-row budget cut renders under the main agent

The budget can keep a finished child and cut its finished owner. The child still
named that owner, so it rendered nowhere. The selection now clears an owner it
did not keep, as the view contract says for an owner outside the projection.

* fix(native-chat): a stop-all that times out stops asking, and "no children" is sent once

A stop-all kept asking after a request timed out, so a Claude CLI that stopped
answering control requests cost one full deadline per task, while the chat's
sends, Stop and /compact waited behind it. A timeout now ends the loop; other
request failures still let the remaining tasks be stopped.

The chat strip channel never remembered that it had sent "no children", so
every change in a chat with none re-sent that frame to each subscriber. It now
remembers it, and forgets only when the conversation closes.

Also renames agent-child-work-stop.ts to agent-child-work-stop-targets.ts, which
says what it answers: the provider ids a background Stop reaches.

* fix(native-chat): the status feed reads a journal snapshot with no submissions, and e2e tests use the child-work reader

CI on dbd2439cd4 was red in three places:
- first-work-branch-rename and the agentSession.subscribeStatus RPC test feed the status feed a
  journal whose snapshot lists items only. The projection reads the user's newest accepted send
  from `snapshot.submissions`; it now tolerates their absence, as the status projection beside
  it already did.
- the cross-version downgrade test still passed `backgroundTasks` to the teardown's working
  marker, which now takes `childWork`.
- an e2e unit test still gave the status feed the removed `readBackgroundTasks` dependency
  (harmless at run time, a type error in the tests/ project).

* fix(native-chat): the chat strip lists running children only, by the sidebar's rule, and hides when none runs

A finished subagent's result is already in the transcript ("Ran N subagents ·
completed"), so the strip is for work that runs. It now lists exactly what the
sidebar lists, by one predicate (a running child, or a finished one whose own
shell still runs, which reads monitoring), and the host sends no roster once none
runs, so the strip hides.

Gone with it: the 100-row budget and the running-then-newest-finished
selection, the re-homing of a child whose owner the budget cut, and the RPC
gate's rule for a roster of finished rows only (no such roster exists now).
Older clients still get their derived task list, running work only.

Finished records still stay in the host's store until the user's next accepted
message: they refuse a late frame of their run, let a task's own ending replace
an acknowledged Stop's, and keep a running shell's owner. Dating that retention
by when the user wrote the message only kept finished rows visible longer, so it
is removed.

* fix(native-chat): the strip shows running work only from any host, and hides after a released session's last child

- A new app paired with an older host no longer shows that host's finished task
  rows: the strip lists running work only, whatever host sent it, and hides when
  an older host's roster has only finished rows left.
- A test for the path that hides the strip when a session's last running child
  settles after the provider let go of the session (Claude's release path): the
  channel sends `null` though no provider answers for the session any more.
- A test comment still described the strip keeping finished children.
2026-09-30 18:23:24 -07:00
Brennan BensonandClaude 24edf0f64b fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm (#23467)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* fix(native-chat): a request that failed reads as failed

A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.

The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): a verdict change republishes the mobile status projection

* refactor(native-chat): the store's retention trigger keeps its flag compare

A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.

* test(native-chat): a user message the provider journaled keeps its session listed

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a late provider-session update keeps a failed recovery record failed

A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* test(native-chat): the terminal-bell check asserts the renamed verdict field

The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.

* fix(native-chat): a failed turn ranks like a completion for attention

Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.

The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.

* fix(native-chat): a failed main agent reads failed while its subagents still work

The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.

Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.

worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.

* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it

The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.

* docs(native-chat): the status-store listing rule names provider-journaled user messages

* fix(native-chat): a refused send notifies failed through the completion feed

The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.

* fix(native-chat): every copy of a row carries the main agent's own status

History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.

- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
  matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
  malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
  as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
  rebuilding one; the sync key and history equality compare it.

* test(native-chat): pin the worktree ps verdict across host and phone versions

Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.

* test(mobile): name the parity table's row for its role

* fix(native-chat): a request that settles while the user is asked something notifies once

The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.

The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.

* fix(native-chat): the completion says when the user is being asked

A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.

The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.

* fix(worktree-status): a departed agent's failure yields to live work on the worktree card

A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.

* docs(agent-status): a departed agent's failure ranks below live work on the worktree card

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(cross-version): load the phone row readers without mobile's toolchain

Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.

The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.

* test(cross-version): keep the checkout path-guard message and justify the copy import's cast

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm

When the provider gave no verdict, the structured status projection now derives
one from the newest turn's lifecycle: interrupted -> interruption, unverifiable ->
unconfirmed. Nothing new is journaled, the completion feed stays provider-only, and
the legacy interrupted flag stays a user stop only. Every verdict reader handles
both arms explicitly.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* fix(native-chat): a folded turn a crash cut off reads Interrupted after N

The settled-turn timing now carries the turn's verdict, derived by the same
agentTurnVerdict the status row uses. A turn that ended interrupted with no
provider verdict heads its fold 'Interrupted after N'; a user's stop keeps
'Worked for N'.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* fix(native-chat): the user's close of a chat records the turn it cuts short as their cancellation

The expected-close settle writes outcome cancellation when the user aimed the
stop at this chat: a Stop while the agent starts, the chat's tab closed (the
agentSession.close RPC, or session.tabs.close with a user reason), or /clear.
A quit, an idle eviction, a worktree teardown or an orchestration stop leaves the
turn with no verdict, so it still reads Interrupted.

* test(native-chat): a Claude turn a newer send superseded reads Interrupted

The supersede fires for any send Orca dispatched, the user's or another agent's,
and nothing at that site records the sender, so the turn keeps no verdict and
folds as Interrupted after N.

* fix(native-chat): the user's close records cancellation on the turn the provider settled on its way out

The Codex and Claude adapters settle their open turn as interrupted, with no
verdict, while the host stops them. The expected-close settle then found no
running turn, so a user's close of a mid-turn chat read Interrupted. The stop
now reads the running turns before it reaches the provider and records the
user's cancellation on each one it cut short, unless the provider gave a
verdict of its own. The close-verdict test's adapter now settles its turn on
close the way the real adapters do.

* test(native-chat): update the close and settled-turn expectations for the host-observed verdict

agentSession.close now passes the user's word to the host, and a settled
interrupted turn with no provider verdict carries `interruption`. Also merge a
duplicate import the code-quality gate rejects.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* fix(native-chat): the user's close cancels a turn whose start landed as the provider stopped

The close read which turns were running before the stop. A turn whose start was
still in flight (a send echo not yet journaled) was absent from that read, so the
provider's verdict-less settle on the way out left it Interrupted. The close now
reads which turns were already over instead, and records the user's cancellation
on every other turn the stop left running or interrupted with no verdict. A turn
cut off earlier, or finished during the stop, keeps its end.

* fix(native-chat): a send the provider never received after a restart has no verdict

Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.

* refactor(native-chat): the adapter settles the turn a stop cuts with the stop's typed cause

The host hands its stop's cause to the adapter's close. Each adapter settles its own
open turn on 'ended' through one mapping, turnVerdictForChildEnd, and the host's
dead-generation fallback uses the same mapping for any turn no adapter settled. A
user's close or stop of this chat is their cancellation; a quit, eviction, teardown
or an exit the adapter saw first is news.

Deletes the snapshot-and-diff reconstruction (endedTurnItemIds,
userStoppedTurnRevisions, settleUserStoppedTurns) and the requestedByUser flag.
host.close now takes a required cause.

* test(native-chat): a user's close drops the chat's status row like an eviction

* test(native-chat): expect the eviction cause on the host closes of idle release, worker stop and worker discard

The stop's typed cause now travels into host.close and the adapter's close, so these
three non-user closes assert the 'evict' they pass.

* refactor(native-chat): every stop names its cause, so none defaults to the user's cancellation

stopStructuredAgentSessionAgentUnderSerialize defaulted its ending to 'user-stop', which now
settles the cut turn as the user's cancellation. Every caller already passes a cause; the
parameter is now required, and a type-level test fails to compile if the default returns.

* test(native-chat): pin who a chat's session.tabs.close speaks for, older clients' reasonless close included

The mapping lived inline in a type-unchecked file, and only the explicit user reason had a test: an older client's reasonless close, or a lifecycle echo read as the user's, stayed green. It is now one exhaustive, type-checked function with a case per reason.

* style(mobile): draw the unconfirmed dot in the theme's status amber, not an inline hex

* docs(agent-status): the main agent's outcome also carries the host-observed end, interruption or unconfirmed

* fix(native-chat): a chat the user closed while its agent started is not a failed start

A still-starting child the user's close cut counted as a failed start, since only 'user-stop' was excluded: a start-failure row, and queued messages rejected as a provider failure. Whether an ending fails its start is now one exhaustive switch, shared by the failed-start read and the delivery loop's handover: a user's stop or close never does; an exit, a failed attach and the host's own stops still do.

* fix(native-chat): a chat the user closed closes its queued messages, and starts no agent for them

After 876b6989f1 a user's close of a still-starting chat went on like a Stop, so when the close did not complete the delivery loop started a new agent for the message queued behind it. A child's end now has three dispositions, not a failed-start boolean: a user's Stop lets the queue go on, the user's close closes what was queued before it, and any other end fails it. The close is the one a completed close does (the provider-closed rejection, no verdict), applied at the top of each delivery step and ordered against the close so a later send still goes on.

* fix(activity): a crash-cut turn draws the interrupted glyph; only a user's Stop keeps the done check

The Activity page drew every Interrupted row with the done check, which #2569 chose for a user's Stop. With a crash now reading Interrupted, that put a green check on a turn nobody asked to stop. The row's glyph is now an exhaustive switch over the verdict: a cancellation keeps the done check, and an interruption draws the existing interrupted dot. An unconfirmed end already drew its own glyph.

* fix(activity): the Interrupted group header draws the done check only when every row is a user's Stop

A user's Stop and a crash share the Interrupted group, and its header took its newest row's glyph, so a Stop newer than a crash put a green check over the crash. The header is now folded over the group's rows: the done check only when every row draws it, the interrupted dot otherwise.

* fix(native-chat): a failed close of what the user closed starts no agent for it

Rejecting the messages a user's close left queued swallowed a journal write failure, so the delivery step went on to start an agent for a message in a chat the user closed. The rejection now reports whether it landed, and a step whose rejection failed stops instead; the next wake re-derives and retries it. Also pins that the ordering against the close holds only within its epoch, since a later epoch's sequences restart.

* test(native-chat): the idle sweep's stop is an eviction, so its close carries that cause

Main's idle sweep now stops an idle agent through the conversation lifetime, which this branch gives the 'evict' cause; its expectations name it.

* fix(native-chat): a retried stop keeps the cause of the stop it finishes

A user's Stop or close whose wind-down failed after the child was proven gone was finished by the idle sweep as an eviction, so the turn it cut read Interrupted. The owed wind-down now carries its stop's cause, and a retry with no child settles with it.

* test(native-chat): the idle sweep's close of a retrying Claude chat carries the eviction cause

Main's new test expected the adapter close with the session id alone; every stop now names its cause, and the idle sweep's is 'evict'.

* fix(status): a user's Stop marks done on the tab and sidebar; red Interrupted is only a turn cut short by something else

The tab, the worktree card and the sidebar rows drew a Stop with the same red dot as a crash. The
verdict mark now maps a cancellation to done, still saying "Interrupted by user" in the row text,
and the mobile mirror follows. The Activity page keeps grouping a Stop under Interrupted with the
done check, as before.

* test(cross-version): a new phone reads a user's Stop as done; an old phone still draws it interrupted

* fix(native-chat): a user's Stop inside a live Claude chat reads as their cancellation

Stopping a running Claude turn interrupts it and keeps the session, so the turn's end comes from
the CLI's result frame. Claude CLIs before 2.1.91 send that frame with no terminal_reason, and later
ones may still omit it, so the user's own Stop was recorded as a failure with an error row.

Orca now records the stop on the open turn when it sends the interrupt. An error result for that
turn reads as the user's cancellation whatever reason the CLI gives. The stop belongs to that one
turn, so it cannot reach the next, and it is withdrawn when the CLI refuses the interrupt.

* docs(agent-status): a user's stop marks done; name the tab close cause by its type

The reference still said a stop marks a row interrupted and ranks between live work and an
unconfirmed end. A cancellation now marks done, and only a turn cut short by something else ranks
as interrupted. The runtime's tab close restated the close cause's union; it now uses the type.

* test(native-chat): a proven crash reads as an interruption on the status feed and in the chat

A crash the relaunch proves now settles its turn interrupted, and the status feed works the verdict
out from that record, so the restart test expects interruption for a proven crash and unconfirmed
for one it cannot prove, never a cancellation. A chat read before the proof lands reports
unconfirmed, then interruption and a folded "Interrupted after 27s" once the proof revises it.

* fix(status): a user's Stop reads Interrupted, and a turn anything else cut short reads Failed

The verdict mark now maps a cancellation, the user's own Stop, to interrupted, and an interruption,
a turn cut short by a crash or a killed agent, to failed, the same as a failure, which outranks live
subagent work. An unconfirmed end is unchanged. This applies to every agent, in a terminal or a chat,
on the tab, the sidebar rows and worktree card, the dashboard row, Cmd+J and the phone. A Stop is
not news, so the rollups rank it below an unconfirmed end, and notifications word an interruption
"failed". Recording is unchanged.

* fix(activity): group a user's Stop under Interrupted and a crash with failures

A user's Stop draws the interrupted glyph and sits alone in Interrupted, and a turn anything else cut
short sits in Failed, titled "Agent failed". Every row in a status group now draws the group's own
glyph, so the header is the group's status and the rule that folded a Stop's done check into the
header is gone. Interrupted ranks below an unconfirmed end, as in the sidebar.

* fix(status): draw a user's Stop in the muted tone, not the fault red

The interrupted dot, which now means only a user's Stop, draws in the muted foreground token on the
agent rows, the sidebar card and the phone. Red stays for a failure or a turn cut short by anything
else, and green for a finish.

* fix(native-chat): fold a stopped turn as "Interrupted after N" and a failed or crash-cut one as "Failed after N"

The settled turn header now follows the verdict mark: a user's Stop reads "Interrupted after N", and
a failure or a turn anything else cut short reads "Failed after N", under the new key
components.native-chat.status.failedAfter in all six catalogs and the boot catalog. Desktop and
phone share the one description, so they agree.

* docs(agent-status): describe the Interrupted and Failed marks

The reference and the phone's turn bar still described a user's stop as done and a crash as
interrupted. A fault now reads failed, a user's stop reads interrupted in the muted tone, and the
rollups rank an unconfirmed end above a stop.

* test(status): a crash the relaunch recovers marks failed

The recovery test still expected a recovered interruption to mark interrupted; it now marks failed,
as a failure does. Formatting only elsewhere.

* fix(native-chat): record a turn a newer request replaced as superseded, and show it Interrupted

A Claude turn that a newer send replaced before its result arrived was recorded as interrupted with
no verdict, which reads as a turn cut short by something else, now "Failed". It is now recorded with
its own outcome, `superseded`, where the replacement is detected. That outcome names no sender, so a
dispatch from another agent is never recorded as the user's Stop, and it sets no legacy flag.

Every reader handles it in an exhaustive switch: it draws the muted Interrupted mark with the plain
text "Interrupted", folds as "Interrupted after N", and attention demotes it with a Stop, through
the renamed agentTurnEndedOnRequest. Older builds read an arm they do not know as no verdict, which
is what this turn carried before, so their rows keep reading done; the cross-version suites pin an
older desktop's journal and status readers and an older phone.

* refactor(status): name the attention predicate for a turn ended on purpose

agentTurnEndedOnRequest becomes agentTurnEndedOnPurpose: a user's Stop or a newer request's
replacement, never a fault. The Claude turn-end comment no longer says a replaced turn carries no
verdict.

* test(native-chat): a turn cut off by a restart or by quitting Orca reads Failed after N

On main a restart-cut turn shows the done tick. Pin the chat's turn bar and
the tab's mark for both cuts, through the recovery settlement and the quit's
child-end mapping, and pin the quit's turn bar through the host's own quit.

* style(native-chat): format the superseded turn-bar expectations

* fix(claude): a Stop that names no turn is the user's stop of the open turn

The chat's Stop button names no turn. Claude's conversation Stop recorded the
user's stop only for a named turn, so an older CLI's error result after that
Stop read Failed. It now records it on the open turn through the same intent,
dropped when Claude refuses the interrupt and never carried to the next turn.

* fix(native-chat): keep the attach context's publishStatus required

The lifetime context type makes publishStatus optional, so the attach context
that spreads it no longer satisfied its own type once its duplicate
publishStatus went. The host's lifetime context is now inferred, and checked
with satisfies, so the spread carries the member it always sets.

* fix(activity): rank a user's Stop below live work in the status grouping

The Activity page's status grouping put the Interrupted group (a user's Stop, or a
turn a newer request replaced) above Working and Monitoring, so a Stop still sorted
like news there while the sidebar, worktree card and Cmd+J rank it below live work.
It now follows live work and stays above Done; Failed and Couldn't confirm keep
their places above live work.

* docs(agent-status): say which turn outcomes the journal records and which are derived

The journal now records superseded as well as the provider's verdict and a stop;
interruption and unconfirmed are derived on read. The resume row no longer claims
interrupted renders red.

* test(native-chat): the idle sweep's held-send rest closes with the evict cause, like its siblings

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-30 15:02:44 -07:00
Brennan Benson 5cda0f4508 refactor(native-chat): keep agent-session records in the chat journal database (#24006)
* fix(native-chat): report a failed startup chat reconcile instead of failing app startup

At startup the chat host re-checks every saved chat's lease and writes the
result to agent-sessions.json. If that write failed (the file lock gave up,
the file could not be written, or the file was written by a newer Orca and
is read-only here), reconcileRestartLeases rejected, the startup IPC call
rejected, and the renderer fell into its degraded "Session restore failed.
Changes won't be saved until restart" mode.

The reconcile is bookkeeping: a lease left unreconciled grants no writer,
and every attach, send and read of a chat reconciles its own lease again.
So the startup reconcile now reports its failure through a new optional
host dependency, onStartupReconcileFailure, and resolves. The runtime
routes it to its onError sink under the scope
structured-agent-session-startup-reconcile, or logs it when no sink is
installed (the desktop installs none).

* fix(native-chat): read restored chats without waiting on lease bookkeeping

With native chat on and a chat tab open at quit, the renderer's startup
also awaits the chat tab restore (session.tabs.listAll). That restore
re-ran the lease reconcile before reading each chat and rethrew its store
failure, then recorded each restored tab as visible through a store
transaction that throws on a held lock or a read-only store. Either one
failed the restore, so startup still fell into "Session restore failed".

Reading a chat grants no writer, so the reconcile startup and the restore
run is now a reader's: createReaderReconcile never throws, answers whether
every lease is settled (recovery is resolved only then; the journal opens
either way), and reports each distinct failure once until a reconcile
settles. Attach and agent start keep the strict reconcile. The restore's
tab republish logs a failed visibility write and still publishes the tab,
since a client drops every unpublished chat tab; user-driven publishes
still refuse.

The host dependency is renamed onLeaseReconcileFailure (scope
structured-agent-session-lease-reconcile), since it now also reports for
reads.

* fix(native-chat): keep every record-store write off the startup chat read path

Round-2 review found two more writes on the startup chat restore that
could still fail it and put the app into "Session restore failed":
republishing a /clear replacement recorded its tab visibility strictly,
and resolving a chat's recovery rethrew its store error. The restore
also paid one lock wait per tab and per batch of chats while the lock
stayed held.

The restore now derives tabs from state it already holds:
- publishStructuredAgentSessionTab splits into the strict write and
  projectStructuredAgentSessionTab, which only updates the runtime's
  snapshot. The restore and /clear replacements only project: a saved
  tab index already lists every restored chat, and a /clear moves the
  tab in the same write that commits it. visibilityWriteMayFail is gone.
- Chats a legacy profile restores that the index does not list are
  recorded in one best-effort transaction (store.showSessionTabs), so a
  failure leaves the index absent to seed again rather than partial.
- The read restore's recovery resolution is caught and reported through
  onLeaseReconcileFailure, deduplicated with the reconcile's reports.
- Once lease bookkeeping fails in a restore pass, the rest of that pass
  skips it, so a held lock costs one wait for the startup reconcile and
  one for the restore, however many chats are open.

User actions (create, reveal, attach, send, the /clear commit) keep
their strict writes.

* test: open, seed and read the agent-session record store through one harness

Tests that open the durable agent-session record store, seed it, or read
back what it persisted now go through agent-session-record-store-test-harness.ts
instead of calling AgentSessionRecordStore.open or touching agent-sessions.json
themselves. A later change that moves the store into the chat database then
changes the harness instead of every test. No production code changes.

Tests whose subject is the JSON file itself (its .bak recovery, salvage,
schema versions, permissions, and what older builds read back) keep reading
and writing the file directly; the storage move rewrites or deletes them.

* fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure

The restore now runs one reader lease check for the pass and lets each chat
re-check and resolve recovery only while the pass is still settled. The
first refusal or failed write clears it for the rest of the pass, and every
chat is still opened for reading. With another process holding the lock,
startup waits on it once in prepare and once in the restore, however many
chats are open; a legacy profile waits once more for its tab-index seed.

* docs(native-chat): correct restore comments and a test name to match the final design

* test: address the record-store harness by the host's state directory

The harness took the store's own folder, so each caller picked one
(join(root, 'store'), or 'agent-sessions' where a test read the store the
runtime owns). A later change that moves the store into the state
directory's journal database could not tell those apart, and would have
had to edit every caller again.

Every harness function now takes the state directory, the one the test's
journal database and recovery capsule already live in, and keeps the
store in the same subfolder the runtime uses. Callers pass that directory;
store-only tests pass their temp directory unchanged. Format tests that
share a directory with harness calls take the file path from
testAgentSessionStoreFilePath.

The folder name moves from a private constant in the runtime to
AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness
shares it without importing the runtime. Its value and every path built
from it are unchanged.

* refactor(native-chat): keep agent-session records in the chat journal database

The record store's records, operation ledger, retired claim keys and chat tab
index become tables in agent-session-journal.db (user_version 4). The version-4
migration copies agent-sessions.json in its own transaction and never writes,
renames or deletes that file or its .bak. Each store write is one journal
transaction over exactly the rows it changed, checked with the load rules; the
file lock, the external-change refresh and its hash, the .bak rotation, salvage
and the hot-path recovery fence are gone from the store.

* wip: importer tests

* test(native-chat): cover the records migration, the import, row writes and read-only records

* docs(native-chat): retire comments that describe the records file as the live store

* test(native-chat): drop the record-store harness's leftover file path and type the import fixture

* test(native-chat): let the host harness cleanup wait out a recovery-offer read's lock

* fix(native-chat): let Stop reach the agent when its ledger row cannot be written

Stop's operation-ledger row now shares the database with the chat history, so
damage, a full disk or a stranded transaction on that write refused the Stop
before the interrupt. A cancel plan now takes its decision from the committed
ledger in memory, runs without settling, and warns that the row was skipped.
Other mutations answer proven damage with the typed "Unable to load this chat."
refusal instead of the raw SQLite error.

* fix(native-chat): answer whether a profile holds chats from the database's rows

Every host install creates agent-session-journal.db, chats or not, and the
version probe created it too, so its mere existence made every profile that
ever installed the host wait on host install and reconcile at startup. The
check now opens the database read-only and looks for a record or tab row,
lets the records file answer while its import is still owed, and counts an
unreadable database as present. The version probe no longer creates the file.

* fix(native-chat): open a chat from history when its tab index cannot be written

Over records a newer Orca wrote, every write is refused, so opening a closed
chat from Agent Session History failed on the tab-visibility write and the chat
read as unreachable. Like closing a tab, opening one now reports a failed
restore-index write and still publishes the tab.

* fix(native-chat): keep the records import owed when the backup read fails transiently

A torn records file whose .bak could not be read (EACCES, EIO) was reported as
unusable, so the migration completed with nothing copied and never retried.
A non-ENOENT read failure of either copy now carries its cause, which the
importer classifies as a read that can clear.

* test(native-chat): pin that an unreadable records file never falls back to its backup

* fix(native-chat): restore imported chats' tabs when the records file had no tab index

A chat created while the import was owed recorded a tab index holding only
itself. When the file it later imported had no index, that index still read
as recorded, so the imported chats' tabs never came back. The import now
clears the recorded marker in that case, and restore falls back to the
profile's tabs.

* refactor(native-chat): drop the unused in-transaction store write

Nothing called it, and it bypassed the write queue and the read-only refusal.

* docs(native-chat): say that an unusable records file is left untouched but never re-imported

* refactor(native-chat): keep the provider handle chain check as main has it

The chain-validation refactor has no measured need in this change.

* docs(native-chat): retire lease-renewer comments that describe the records file as the live store

* fix(native-chat): keep a throwing failure sink from failing the startup chat read

The lease bookkeeping failure reporter called the host's failure sink
directly, so a sink that threw turned a reported, recoverable store failure
back into a rejected startup reconcile or read restore. The reporter now
catches a sink throw and logs both the original failure and the sink error
with console.warn.

* test(native-chat): wait for a replaced host's restart-offer writes before cleanup

A restart test replaces the host without tearing the old one down, so the old
host's fire-and-forget restart-offer withdrawal could still hold the recovery
capsule's lock directory when cleanup removed the test directory (ENOTEMPTY).
The harness now hands hosts a capsule that tracks running operations and waits
for them before removing the directory, replacing the rm retries.

* docs(native-chat): retire the abandon helper's note that the store re-creates its directory

* fix(native-chat): restore a chat opened while the import was owed beside the profile's chats

When the imported records file had no tab index, restore fell back to the
profile's saved tabs, which never list a Claude chat, and the seed then
rewrote the tab table without the chat opened while the import was owed.
The tab rows that chat left are now loaded as unrecorded, restore takes them
together with the profile's chats, and the seed keeps their tab ids.

* test(native-chat): pin that a create whose tab index write fails still opens the chat

* docs(native-chat): say why restore puts chats opened while the import was owed first

* test(native-chat): replace a ledger row rather than change it in place in the Send-now rerun test

The record store freezes published rows in tests, so setting a row's outcome
in place threw; the test now swaps in a changed copy, as its sibling cases do.
2026-09-30 14:45:32 -07:00
Brennan Benson 75040eba5a test: open, seed and read the agent-session record store through one test harness (#23986)
* test: open, seed and read the agent-session record store through one harness

Tests that open the durable agent-session record store, seed it, or read
back what it persisted now go through agent-session-record-store-test-harness.ts
instead of calling AgentSessionRecordStore.open or touching agent-sessions.json
themselves. A later change that moves the store into the chat database then
changes the harness instead of every test. No production code changes.

Tests whose subject is the JSON file itself (its .bak recovery, salvage,
schema versions, permissions, and what older builds read back) keep reading
and writing the file directly; the storage move rewrites or deletes them.

* test: address the record-store harness by the host's state directory

The harness took the store's own folder, so each caller picked one
(join(root, 'store'), or 'agent-sessions' where a test read the store the
runtime owns). A later change that moves the store into the state
directory's journal database could not tell those apart, and would have
had to edit every caller again.

Every harness function now takes the state directory, the one the test's
journal database and recovery capsule already live in, and keeps the
store in the same subfolder the runtime uses. Callers pass that directory;
store-only tests pass their temp directory unchanged. Format tests that
share a directory with harness calls take the file path from
testAgentSessionStoreFilePath.

The folder name moves from a private constant in the runtime to
AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness
shares it without importing the runtime. Its value and every path built
from it are unchanged.
2026-09-29 23:38:43 -07:00
Brennan Benson 59b746ff3c feat(native-chat): one structured-chat journal database per host, owned by one process (#23613)
* feat(native-chat): one structured-chat journal database per host, owned by one process

Every structured chat on a state directory now lives in one SQLite file,
agent-session-journal.db, opened once by the process holding
agent-session-journal.owner: an empty SQLite file whose held BEGIN EXCLUSIVE is a
kernel byte-range lock, refused while another process holds it and released when
the holder dies.

- Stores own no connection: the per-chat handle, its close contract and the
  close-retry registry are gone; closing a conversation drains its writes, and the
  one connection closes last at teardown.
- The owner lock is taken at runtime start, before orca-runtime.json is written;
  a process that does not own the chats is not published and refuses every
  structured request with journalUnavailable and words that say what to do. It
  retries the lock with backoff and runs the full install once it holds it.
- A journal that will not open fails the host install: every chat says "Unable
  to load this chat." (journalCorrupt), and nothing is renamed, deleted or
  rebuilt. A newer build's database is refused and left byte-identical.
- An append is one INSERT. The listing status is a column, written after the
  rows it describes and keyed by (epoch, sequence).
- A per-chat journal from an earlier build is copied in verbatim (epoch UUID and
  every sequence) on that chat's first open, and its directory is retired only
  after the copy commits.
- auto_vacuum = INCREMENTAL, with freed pages handed back in bounded steps after
  every delete.

* perf(native-chat): key journal rows by block so one chat's rows sit together

Each chat's live epoch owns a block of row ids, block * 2^32 + seq, so a chat's
rows share leaf pages with nobody else's, a replay is one range scan, and
replacing or rewinding a chat deletes one contiguous range. Measured on the
largest real chat (61 MB) beside 19 interleaved peers: 39 ms and 7.5 MB of WAL,
against 214 ms and 102 MB for a (session_id, epoch, seq) key.

- Ids are computed in Number arithmetic, never bitwise. A sequence is refused
  outside [1, 2^32) and a block at 2^21, which keeps every id below 2^53.
- A replace, roll or import allocates a fresh block, moves the chat's pointer,
  and deletes the old block in the same transaction, so no orphan block exists.
- The listing status write moves into its own writer beside the column.

* feat(native-chat): copy a chat's per-chat journal again when an older Orca wrote it after a downgrade

A per-chat journal.db that reappears after its chat was copied in is the newer
history: an older build, run after a downgrade, attached the chat and wrote it.

- journal_imports records the (epoch, tip) each chat was copied from, in the
  copy's own transaction. A file already copied is never copied again, across
  any number of restarts after a failed rename; a file that differs always is.
- Newest writer wins, per chat, with a row saying the chat was continued in an
  older version of Orca. When both builds wrote past the recorded tip under one
  epoch, the copy takes a fresh epoch, so readers reset instead of skipping rows.
- Each copied directory retires to its own .imported-<epoch8>-<ms> name, so a
  second downgrade and re-upgrade never collides with the first.

* test(native-chat): fixture deps match the host journal database shape

Attach-flow and reconcile-attach fixtures stop passing a journal database those inputs do not take, and host and restore fixtures pass the one they now require instead of the removed journal root.

* test(native-chat): state why the runtime-state fixtures' existing casts are safe

* fix(native-chat): start up normally when this process cannot open the chat journal

A process refused the chat journal, because another Orca owns it or because its own journal will not open, failed startup restoration: the window booted in degraded no-save mode and a paired phone could not list any tabs. Startup restoration now treats the refusal structured requests are getting as having no structured host; terminals, tabs and saving go on, structured requests are still refused by the gate, and the install is retried on the next one. Any other install error fails startup as before.

* test(native-chat): name the owner-lock sweep test after the two sweeps it runs

* fix(native-chat): session history and terminal resume work while chats are refused

Session history (listing and preparing a resume) and a terminal typing a resume command only check whether a structured chat owns a provider session. In a process refused the chat journal they failed outright. They now take the refusal chats are getting as having no structured host, the same treatment startup restoration gets, through one shared helper; any other install failure still fails them. Chat requests keep the gate's refusal.

* test(native-chat): the first-work rename's fake journal saves the listing status

* fix(native-chat): open a chat whose per-chat journal file never got its schema

A crash between creating a chat's journal.db and creating its tables left an empty or schema-less file. Each chat used to open that file as an empty chat; the importer instead refused the open as "try again" forever. A file with no journal_sessions table is now read as never written, the same as one with no rows. A file that is not a database, or whose read fails, is still refused.

* fix(native-chat): let the event loop run between chats during startup restore

Opening a chat's journal is synchronous SQLite now that no per-chat directory
is created first, so the restore of every visible chat ran as one main-thread
task. Each chat now waits for a macrotask before it opens.

* fix(native-chat): import a per-chat journal in bounded batches

The one-time copy of an earlier build's per-chat journal ran as one
transaction, which blocked the main thread for 650 ms on the largest chat.
Rows now copy 512 at a time, each batch its own transaction, yielding to the
event loop between batches. The rows go into a block journal_import_blocks
reserves, which no reader follows and no other chat is allocated; the last
batch publishes the chat's pointer, repair marker and import marker together
and releases the reservation. A copy that stops midway leaves only that
block, which the next open clears and copies again. Two opens of one chat
import one after the other.

* fix(native-chat): refuse chats when the owner lock file cannot be opened

A lock file that is not a database, or cannot be opened, made the claim throw
before any refusal was recorded, so startup restoration failed on every
launch. The claim now sits in the same try as the database open and records
the same typed refusal.

* fix(native-chat): finish reclaiming pages a delete frees during a running pass

A reclaim pass ended as soon as the freelist stopped shrinking between steps,
so a second delete that freed more than one step's worth mid-pass ended it
early and left those pages on the freelist. A pass now ends only when a step
itself frees nothing, or the freelist is empty.

* test(native-chat): desktop session history is served while chats are refused

* fix(native-chat): a send to a chat holding a newer Orca's rows says to update

A chat opened read-only because a newer Orca wrote rows to it answered a send
with the generic write failure. It now refuses the way a database a newer Orca
wrote does, with the same reason and words.

* fix(native-chat): keep chat tabs while this process cannot list its chats

A process whose chats another Orca owns, or whose chat journal will not
open, has no structured host. Its session-tabs inventory still answered,
with no chat rows, and the renderer read that as "every chat was closed":
it removed the restored chat tabs and the next session save persisted
their placement away.

The inventory now says `agentSessionsUnverifiable` when the last tab
restore ran with chats on disk but no host to list them. The flag is set
and cleared at the per-client projection point beside the client-hosted
page hold, and the restore is memoised only once a host answered, so a
later lock takeover or journal open republishes the chats and clears it.
The renderer keeps agent-session tabs, and keeps cancellation tombstones,
against an inventory that does not affirm its chat set.

* fix(native-chat): say chats are open in another Orca, with this process's way past it

A process refused because another Orca owns the profile's chats sent the
generic `journalUnavailable` reason, so current desktop and phone surfaces
said "couldn't open this chat's history right now. Try again." — a step
that never helps while the other Orca runs.

The refusal now names its own reason, `journalOwnedElsewhere`, with the
refused process's kind (dev desktop, packaged, orcad) as a fact. Each kind
gets its own step: quit the other Orca, or give this one its own profile
(ORCA_DEV_USER_DATA_PATH) or data folder (ORCA_USER_DATA). The sentences
are added to the shared notice copy, the desktop catalogs in all six
locales, and the boot catalog.

A client that predates the reason reads it as none and keeps the code's
words; an unknown kind reads as the packaged app's step. The `message`
released clients print is unchanged. Which requests refuse does not change.

* fix(native-chat): restore chats on taking ownership, without a list to ask

A refused startup kept its hostless result, so after the owner quit this
process never installed a host, never reconciled restart leases, and kept
telling clients it could not list its chats until a desktop chat request.
Taking the lock now reruns startup restoration once and pushes the chats.

* test(native-chat): a navigation reply says chats are unverifiable while refused

* fix(native-chat): retry a refused owner lock at most every 5 seconds

The lock frees as its holder exits, but a refused process only learns that
on its next retry, and the 30 s cap left a second Orca refusing chats for up
to half a minute after the owner quit. One retry is an open and BEGIN
EXCLUSIVE on an empty file.

* fix(native-chat): install before deciding whether a takeover must republish chats

A list that landed on the refused startup after the lock was taken finished
after the takeover had already checked, so nothing republished. The takeover
now installs first, waits for any restore in flight, and restores only then;
the restore that clears "cannot tell" pushes the frames itself, so a list
that heals the inventory first reaches subscribers too.

* fix(native-chat): no takeover lands a host after the runtime stop

Quitting cancelled a refused claim's retry only at its end, so a retry firing
during the stop's awaits took the lock and installed a host the stop never
tore down, and the lock was then released under an open journal. The stop
now cancels the retry first, keeping the refusal, and repeats its teardown
while an install that began during it (a takeover already under way) is
pending, so no journal connection outlives the lock.

* fix(native-chat): show a thrown refusal in its own words, not its code

A refusal the host throws reaches the client as an RPC error whose message is
the bare code; its reason and facts ride only in the error's data, which no
client read. The chat pane's status line therefore printed
agent_session_journal_unreadable, a send took the bare "not sent" path, and
other writes said the outcome was unconfirmed.

One shared reader, agentSessionThrownRefusal, now reads the refusal from the
error data. A failed history read shows the refusal's read-history words, a
send keeps the refusal behind its Retry exactly as a returned refusal does, and
the other writes (desktop and phone) name the refusal instead of doubting the
outcome. The phone's read failure goes through the same reader.

* fix(native-chat): log a failed journal open once per distinct failure

Every chat request retries a journal open that failed, which is intended, but
each retry also logged the failure with its full stack: a junk database file
logged the same "file is not a database" error 189 times in a minute. The open
now logs a failure only when its code and message differ from the last one
logged, and forgets it once an open succeeds. The retry is unchanged.

The open moves to its own module beside the runtime, which had no room left.

* fix(native-chat): restore lists a chat from its per-chat file and copies it on first use

Startup restore opened every restored chat, and that open copied the chat's
per-chat file into the host database, so the first boot after an upgrade paid
the whole one-time copy before the chat list appeared.

A restore open now reads a chat that is still in its per-chat file straight
from that file, read-only, with the importer's own reader, and closes the file
before moving on. That read drives the listing, the status row and the
restart offer, as it did when every chat had its own file. The copy becomes
owed work on the chat's write queue: it runs before the chat's first write,
and a reader that reaches the chat awaits it. A chat the host already holds,
or that was copied before, still opens through the import and its reimport
rules, and so does a file whose read needs a repair written.

* fix(native-chat): no host stays registered after a stop an install spanned

Each teardown pass clears the registered host before it awaits an install in
flight, and that install registers its host when it finishes. The pass then
tore the host down but left it registered, so a request after the stop was
served by a host whose journal was closed. The stop now clears the slot once
its passes are done.

* fix(native-chat): checkpoint the journal with a full flush on macOS

synchronous = FULL fsyncs each commit, but macOS fsync leaves the drive cache
unflushed, so FULL alone does not survive a power loss there. With
checkpoint_fullfsync, each checkpoint uses F_FULLFSYNC; elsewhere it is a no-op.
The comment that said FULL alone was enough is corrected.

* fix(native-chat): delete a per-chat journal once its copy verifies

An imported chat's per-chat file was kept under an `.imported-*` name, which
doubled the disk its history takes. The copy now reads back from the host
database before it is published: its items, submissions, epoch and tip must
match the file's. Only then does one transaction publish the chat with its
import marker, and the file and its WAL files are deleted, the directory too
when nothing else is in it (a pre-SQLite transcript there is kept).

A copy that does not match is never published: the file stays, the chat is
refused as unreadable ("Unable to load this chat."), and the mismatch is logged
once. A file left behind by a failed delete or a crash matches the marker, so
the next open deletes it rather than copying it again; a file an older build
wrote after a downgrade still differs, and is still copied again.

* fix(native-chat): verify an imported chat a batch at a time

The check that a copied chat reads back as its per-chat file folded both whole,
each in one synchronous task: over half a second on the largest chat. Both
reads now go a batch at a time between turns of the event loop, like the copy
itself, and count rows as well, so a copy that lost a row with no item in it
is caught too.

* fix(native-chat): restore reads a chat's per-chat file a batch at a time

Restore folded a chat still in its per-chat file in one task, so the largest
chat's file held the main thread for about half a second at startup. The fold
now takes the file a batch of rows per turn of the event loop, into the same
fold a replay uses, and nothing reads it before it is done. The file is still
closed before restore moves on.

* fix(native-chat): end a per-chat copy on a turn of its own

A chat's first open ran the copy's last steps (the verified publish and the
per-chat file delete) and the replay of what was copied in one task. The copy
now yields before it returns, so the replay, which every open runs, is a task
of its own.

* fix(native-chat): commit a per-chat copy's batches without an fsync each

Each 512-row batch of a chat's first-use copy committed under synchronous =
FULL, so a large chat paid one fsync per batch, about a quarter of its first
open. The batches now commit under NORMAL, set and restored in the batch's own
task so no other chat's commit runs under it. The publish that makes the copy
visible still commits under FULL, and under WAL that sync makes every earlier
batch durable with it. A crash before it leaves only the unpublished block,
which the next open clears and copies again.

* fix(native-chat): roll back a chat journal transaction whose COMMIT fails

The shared connection's transaction rolled back only when its body threw. A
COMMIT that failed left the transaction open, so every later write, for any
chat, failed with "cannot start a transaction within a transaction", and reads
saw rows that never committed. Under the unsynced copy the failure also tried
to restore the sync level inside the open transaction, which SQLite refuses,
so the caller got that error instead of the COMMIT's.

One transaction helper now covers the body and the COMMIT, rolls back whatever
transaction survives, and rethrows the original error. Schema creation uses it
too. If that ROLLBACK fails as well, the connection is marked stranded: each
later use retries the ROLLBACK, and until one goes through every chat gets the
same "history unavailable, try again" refusal a journal that will not open
gives. The rollback that frees it also restores the FULL sync level.

* fix(native-chat): keep the chat journal connection until its close succeeds

Closing the journal dropped its connection handle before closing it. A close
that failed left the database reporting itself closed with the connection still
open, so the stop that retried the teardown found nothing to close and released
the owner lock over a live connection.

The handle is now dropped only once the close succeeds. A failed close keeps
the runtime pending and the lock held, and the next stop closes that same
connection before it releases the lock.

* fix(native-chat): publish the runtime only once it holds the chat journal lock

When this process could not open the owner lock file at all (a permission
error, or a file that is not a database), the runtime counted that as owning
the chats and wrote orca-runtime.json. That overwrote the real owner's entry,
so the CLI was sent to a process that cannot serve its chats.

A claim that throws is now refused like one another process holds: the runtime
starts but does not publish, the claim's existing retry keeps asking for the
lock, and discovery publishes once the retry takes it. Chats still get the
refusal for the failure itself, and startup restoration reruns on the takeover
the same way it does after another owner quits. A sole process whose lock file
never opens is not found by the CLI until it does.

* fix(native-chat): keep a chat's history when an older build started it over

The first copy deletes a chat's per-chat file, so an older build run after a
downgrade finds no file and starts the chat from nothing. On the re-upgrade that
fresh file was copied in as the newer history, replacing everything the shared
database held for the chat, and then deleted.

A file whose epoch is not the one last copied and that opens with
`session_created` is now kept: neither copied nor deleted, and the chat keeps
the history it has. A file that carried the copied epoch on is still copied
again, as before.

* test(native-chat): pin which chats startup restore copies

Restore copies a chat still in its per-chat file only when restore itself has
to write to it: settling what the last run left open, here a running tool call
or a send handed over and never answered. Every other restored chat stays in
its file until its first use.

* test(native-chat): pin the copy wait on a read that opens a chat restore opened

A read queued behind restore's open of the same chat reaches the conversation
through its own open rather than the listing. It must still wait for the
owed copy, or it reads the chat before its history is in the one database.

* fix(native-chat): record a set-aside per-chat file so no later open reads it

Setting aside a file an older build started over is decided once and kept in
the new `journal_set_aside` table (schema 2, additive), with the file's epoch
and tip as they were. Every later open of the chat skips the file without
opening it, across restarts and after the older build writes more to it:
anything written there grows from that build's own start, never from this
build's history.

The best-effort delete moves beside the per-chat file reader.

* fix(native-chat): set aside any per-chat file at an epoch this build never copied

A chat's per-chat file is deleted once its copy verifies, so a file that
reappears at another epoch was never this build's history, whatever its first
row says: an older build started the chat over, possibly rewinding it after
(`handle_forked`), or rolled the epoch of a file whose delete had failed.
Copying any of them would replace everything the chat holds, so each is set
aside. Only a file still at the copied epoch is copied again (it grew) or
deleted (it did not). The first-row check is gone.

* fix(native-chat): copy a reappearing per-chat file again only while this build has not written past the copy

A per-chat file that an older build carried on under the copied epoch was
copied again even when this build had also written to the chat since the
copy, or had rolled its epoch. The second copy replaced the chat's block,
so what was sent in this build after the copy was gone for good.

Now the file is copied again only when the chat still stands exactly as it
was copied: the same epoch and tip the import marker recorded. Otherwise it
is set aside like any other file that is not this build's history, left on
disk untouched and recorded so no later open reads it. A second copy
therefore never replaces rows this build wrote, keeps the file's own epoch,
and the fresh-epoch rewrite goes away. The row it adds now says the history
includes what the older version recorded, not that anything was replaced.

* test(native-chat): pin that a chat founded here keeps its history, and the v1 schema upgrade

A chat this build founded has a pointer and no import marker, so a per-chat
file an older build later starts for it is set aside. Nothing pinned that
half of the rule: letting such a chat be copied again replaced its history
and every test still passed. A second test pins that a database written at
schema version 1 upgrades in place, gaining the set-aside table and keeping
its import markers.

* test(native-chat): drop a lost copied row by patching the source, not wrapping it

* chore(mobile): restore the mobile lockfile to main's

* fix(native-chat): pass a classified journal refusal through a send or Stop unchanged

* fix(native-chat): refuse a read whose owed copy fails as a failed open does

* test(native-chat): measure only the replace's WAL in the block-key case

Opening the chats starts a free-page pass that waits one event-loop turn,
and the seed never yields one, so that pass was still pending when the
replace committed. It woke during the async stat and reclaimed the pages
the replace freed, adding ~500 KB of WAL whenever the stat lost the race
(Linux CI). Drain that pass before measuring and stub the replace's own.

* test(native-chat): the RPC fixture's status journal can save its listing status

The status feed now hands every projection to the journal, which decides whether it is worth saving.

* test(native-chat): state why the RPC fixture's status journal cast is safe

* fix(native-chat): refuse a per-chat copy whose rows differ from the file, not only its counts

* fix(bench): build the replay benchmark's baseline arm from the base tree and release its handles on failure

* fix(native-chat): retry a failed listing status save on the next read of a cached status

* refactor(native-chat): drop the chat journal owner lock; the process instance lock already guards the profile

The journal carried its own exclusive lock, with a retry loop, an in-process
takeover, lock-gated runtime discovery and a "chats are open in another Orca"
refusal. Every shipped process kind (packaged desktop, serve mode, orcad)
already refuses a second instance on one profile before the journal opens, so
the lock only ever mattered for dev desktops, which the next commit covers at
the process level instead.

The host now opens its one journal connection at install with no lock. What a
sole process whose journal will not open needs stays: the install refusal
recorded for the gate, the no-host startup path, and the unverifiable chat
inventory, now in structured-agent-session-host-refusal.ts. The unreleased
journalOwnedElsewhere reason, its processKind fact and their copy are removed.

* fix(startup): dev desktops take the single-instance lock, and a second one says why it quit

Dev skipped Electron's single-instance lock so parallel `pnpm dev` runs from
several worktrees would not quit silently, but two dev processes on the
default orca-dev profile then write the same stores at once. Dev now takes
the lock like packaged builds: a second launch on the same profile focuses
the first window and exits with code 3, printing one stderr line that names
the taken profile and how to run another copy (ORCA_DEV_USER_DATA_PATH).

Serve mode, the macOS diagnostic bypass and the E2E harness are unchanged:
an E2E launch still skips the lock unless it sets
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK=1.

* refactor(native-chat): key journal rows by chat, epoch and sequence

Rows in the host's journal database are now addressed by the chat's own
identity, with `(session_id, epoch, seq)` as the primary key, the same
shape each per-chat file already used. The block-keyed layout goes with
everything built on it: the block column and its allocator, the 2^21
block ceiling, and the import's reserved block table.

A first-use copy writes its rows under the file's epoch, which the chat's
pointer does not name until the verified copy publishes it, so no reader
sees a half-copied chat. A try that stopped midway leaves only rows no
pointer names, and the next try deletes them before it copies again.
Replace, rollover and repair delete by (chat, epoch).

This build's history always wins: once a chat was copied or founded here,
any per-chat file that reappears is set aside, and the same-epoch copy
again after a downgrade is removed.

The bounded free-page reclaim after every delete is dropped;
`auto_vacuum = INCREMENTAL` stays at file creation, so a later periodic
reclaim can still be added. Session search keeps its own step.

The schema moves to version 3. Versions 1 and 2 were written only by
unreleased builds of this change and are refused as found, not migrated.

* fix(native-chat): open a chat journal a newer Orca wrote read-only instead of refusing it

After a downgrade, the host's journal database carries a newer user_version. It was refused
outright, so every chat's history disappeared. It now opens on a read-only connection, as the
per-chat journals did: each chat shows what this build can read, from the database or a per-chat
file never copied in, and every write is refused with "Chats were saved by a newer Orca. Update
Orca to keep using them." Nothing is written, copied, repaired or founded, and the file stays
byte-identical. A table the newer schema changed reads as the same read-only refusal, not damage.

* refactor(native-chat): leave the saved listing status to the change that reads it

Nothing in this change reads the per-chat listing status column: it was a stored copy of a fact
the status feed derives, written after every turn end and cleared on every epoch change. The
status_json / status_seq columns, their writer, the saved-status type, the status feed's save and
its retry on a cached projection all go, with their tests. The change that lists chats from a
saved status adds the column back beside its reader.

* fix(native-chat): a chat saved by a newer Orca says to update Orca, not to try again

When a newer Orca wrote the chat journal, this build opens it read-only. A send or a Stop was
refused with the reason `journalUnavailable`, so today's desktop and phone clients chose the
words for an open that can clear: "Orca couldn't open this chat's history right now. Try again."
Retrying never cleared it; only updating Orca does.

The refusal now names its own reason, `journalWrittenByNewerOrca`, whose words are "Chats were
saved by a newer Orca. Update Orca to keep using them." A read refused the same way names it
too. An older client does not know the reason, drops it, and falls back to the code's words
("Orca couldn't read this chat's saved history."), and released clients still print the message.

* fix(native-chat): a chat journal from an unreleased build reads as unusable, not as retryable

A chat journal database stamped with schema 1 or 2 was written only by unreleased development
builds of this change. Opening it threw a plain error, which every chat reported as "Orca couldn't
open this chat's history right now. Try again." Retrying never cleared it.

It now throws a named error that is classified as unusable, so every chat says "Unable to load
this chat." The one log line names the file, says an unreleased development build wrote it, and
says to move it aside. Nothing migrates or renames it.

* docs(native-chat): drop the second-Orca-owns-the-chats case from three comments

The chat-only owner lock is gone, so only a chat journal that will not open leaves a runtime
unable to list its chats.

* docs(native-chat): correct three chat-journal comments the redesign left behind

A per-chat file left without its WAL is set aside, not copied again; nothing runs an incremental
vacuum yet, so the auto_vacuum mode is kept for a later pass; and the idle sweep drops a chat's
in-memory fold, since a chat holds no journal connection.

* refactor(native-chat): stop exporting chat-journal names nothing imports

Each is used only inside its own module now; the teardown's export served a deleted test.

* test(native-chat): name the version-0 test for what it covers, and check every journal table

The test named 'migrates an older user_version forward' covers only a version-0 file that already
has its tables; versions 1 and 2 are refused. The table test now also checks journal_imports and
journal_set_aside.

* fix(startup): a second dev launch's exit line no longer claims it focused a window

The running dev instance may be a background launch or a server, which show no window. The line
now says only that this launch passed its request to that instance.

* fix(native-chat): a failed structured-chat install closes the journal connection it opened

The install opened the chat journal database and closed it only if the record store then failed
to open. A later failure, such as the model catalog wiring or the host constructor, left the
connection open, and the next install opened a second one in the same process. Every failure
after the open now closes it.
2026-09-29 16:42:29 -07:00
Brennan BensonandClaude 29c49aec31 feat(native-chat): hold mid-turn messages in a host-owned queue (#23726)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* fix(native-chat): a Stop that names no turn stops what the conversation has in flight

Between handing a message to the agent and the agent opening its turn, there is no turn id a
client could name, so a Stop in that gap was refused as "already finished" while the agent went
on to answer. A cancel's turn id is now an optional precondition instead of its target: with
none, the host withdraws what is queued and, when the journal still reads working, asks the
adapter to stop whatever the child has in flight. Claude's interrupt is session-scoped, so it
is guarded by fence and acquisition generation rather than a turn identity. Codex interrupts
the turn its latest turn/start answered with until the journal shows one.

A cancel that names its turn behaves exactly as before.

* fix(native-chat): Stop is there from the moment a message is sent

The composer showed Stop only once the agent had opened a turn, so for the second or two after a
send the chat read "thinking" with no way to stop it. Against a host that takes a Stop naming no
turn, Stop now shows whenever the chat reads working (a turn, a queued message, or a handed-over
one still unanswered) or this client still has a message on its way. Pressing it, or Escape,
first drops every outbox entry the journal does not hold yet, so nothing goes out after the
Stop, then sends the conversation-wide cancel. A send already on its way reaches the host ahead
of the cancel, which withdraws it there. Against an older host Stop still needs a running turn.

The unconfirmed-send probe moves into its own hook so the outbox hook stays in budget.

* fix(native-chat): Stop before a turn is gated on its own host capability

A host that accepts sends first (agent-session.accepted-send.v1) can still predate the cancel
that names no turn and would refuse it as invalid, since clients and hosts ship independently.
Hosts that take that cancel now advertise agent-session.conversation-stop.v1, and the renderer
shows Stop before a turn opens, and sends the no-turn cancel, only to a host advertising it.
Every other host keeps a Stop that needs, and names, a running turn.

The host capability probe the accepted-send hook used is generalized so both read one path.

* test(native-chat): a build advertises conversation stop exactly where its cancel may name no turn

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* fix(native-chat): Stop reads the one working rule every session list reads

While Claude retries a rate-limited request it never echoes the message, so no
turn opens: the sidebar read Working from the unanswered send while the composer
showed Send. The chat's working state, the host's session-list status and the
host's no-turn Stop check now call one shared rule instead of three copies.

* test(native-chat): a rate-limit retry pins only that no turn opens, not how its rows are kept

* fix(native-chat): Stop leaves a message waiting on its Retry, and does not show for one

A send that failed holds the queue until the user retries it, and one the host restarted under is
parked the same way. Stop counted both as still on their way, so it showed in an idle chat and
could never go away, and pressing it dropped the failed message along with its Retry.

* test(native-chat): the chat's Stop and a session list read the main agent alike over their own copies

The chat reduces its stream and a list reads the status feed. Driven through the real host for a
rate-limit retry with no turn, a subagent still running after the main turn, and the handed-over
child exiting.

* refactor(mobile): the chat reads the main agent's working state through the shared rule

Behaviour is unchanged: the same two terms, now from the one function the host projection and the
desktop chat read.

* fix(codex): a Stop naming no turn never interrupts an earlier turn

It fell back to the id an earlier turn/start answered with when the latest start went unanswered,
or when the journal showed a compaction Codex had not started, and reported that as stopped.

* fix(native-chat): a Stop naming no turn never says a turn had already finished

When the provider found nothing left to stop, for instance a turn that ended between the host's
check and the interrupt, the chat got "The provider had already finished this turn." for a turn
the Stop never named. It now ends quietly, as a Stop with nothing in flight does.

* fix(native-chat): one Stop the host could not settle no longer refuses every later one

A Stop naming no turn has one operation key per session. When the host could not settle one, it
answered every later Stop under the same id as unknown until the id expired. Once the host says
so, the next press is a new Stop; transport doubt still replays the same id.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* test(native-chat): read Stop operation ids without a cast

* fix(native-chat): a Stop whose answer was lost no longer swallows the next one

A Stop that names no turn has one operation key per chat. When its answer was lost in transit, the
chat kept the id, so every later Stop replayed it; the host answers a replay as already handled, so
for up to a day Stop stopped nothing. The id is now dropped once the call settles, however it
settles. A second press while the first is still on its way still shares its id.

* refactor(native-chat): a Stop naming no target keeps its operation id only for its own call

The chat kept each write's operation id per payload across calls, and dropped it only on some
settle paths. That is right for a write naming what it acts on, but a Stop naming no turn, and a
stop of every background task, share one payload with every later one, so any path that kept the id
made the next Stop replay as already handled and stop nothing. One path was still open: an answer
that arrived after the chat moved to a new fence.

Whether a write names its target is now decided once, before its id is picked. One that names none
keeps its id only while its call is in flight, so a press made meanwhile joins it, and releases it
when the call settles, however it settles. The release runs only while the key still holds that
call's id, so a joined call settling late cannot drop a newer one's. This replaces the per-path
exceptions for a thrown call.

* test(native-chat): read the Stop fences without a cast

* test(native-chat): pin the new id for a named cancel the host could not settle

After the Stop naming no turn moved to a per-call id, the only test of the unknown-refusal release
was gone, and the half that stays, for a cancel naming its turn, could be removed with every test
green.

* fix(native-chat): a Stop pressed after a new message stops it, even while the last Stop is unanswered

A Stop naming no turn shared its operation id with any press made while it was still in flight. The
host runs a chat's writes in order, so a message sent between two presses was accepted after the
first Stop ran, and the second press replayed that Stop as already handled and left the message
running, although the chat had already withdrawn it from the outbox.

A write naming no target now gets a new id on every press and is never kept, so each Stop acts on
whatever is running when the host reaches it. A write naming its target keeps its id exactly as
before. A double press can ask the provider to stop the same turn twice, which it tolerates.

* fix(native-chat): Stop no longer blinks off as Claude opens the turn for a message

Claude's echo of a sent message both answers the send and opens its turn. The echo settled the send
first, so the host published the message as answered one frame before the turn it opened, and for
that frame the chat read nothing running: Stop turned back into Send, and Working blinked off in
every session list, for tens of milliseconds on each turn.

The echo now settles the send after the turn it opens has been emitted, so the running turn is
published first.

* fix(native-chat): a message a Stop withdrew comes back to its sender's composer

A Stop withdraws every message the host holds but has not run, and S also
drops the ones this client had not handed over yet. Either way the message
left the chat and its text survived only in a hidden journal row and the
in-memory ArrowUp history.

The sending client now puts the withdrawn text and images back in that
pane's composer, after whatever is typed there. Withdrawn is read from the
rejection reason through one shared check, which the outbox reconcile now
uses too. The composer is written before the entry leaves storage, so a
failure between the two repeats the text instead of losing it, and an entry
storage no longer holds is never given back again, so a replay, a second
view or a remount restores it once. Only this client's outbox holds the
entry, so other viewers still see the message disappear. A failed Stop
withdraws nothing on the host and gives nothing back.

* fix(native-chat): withdrawn text put back during an IME composition is not lost

While the IME owns the field, the composer ignores a programmatic draft, and
the next composed keystroke wrote the draft without the restored text, after
its outbox entry had already been dropped. The composer now holds text
appended mid-composition, keeps it in the cache after each composed write,
and shows it once the composition settles, the way attachments that land
mid-composition already wait for it.

* test(native-chat): pin that only a withdrawn message comes back to the composer

* test(native-chat): set up the composer's window API for every describe in the composition-race file

* docs(native-chat): note that the withdrawn check reads the legacy reason until a typed category lands

* test(native-chat): pin that text put back mid-composition shows once, even beside a mid-composition clear

* feat(native-chat): host-owned queued-message draft store in the session journal

A queued mid-turn message is a draft row in the session's journal.db,
created idempotently at every writable open with no user_version bump so a
downgrade stays writable. Consume converts one draft into an ordinary
submission inside the journal writer's own transaction (exactly-once), and
a standing writer hook returns a consumed draft only when a committed row
newly settles its current consumed submission to a non-withdrawn rejection
— the same decision the reducer folds rows through. Open-time repair
re-derives returned state behind the stored fact; retention never prunes a
row whose refusal could still return it.

* feat(native-chat): queued-messages wire contract, dark capability, and send classifiers

The send result becomes a union: today's submission arm unchanged, plus a
capability-gated queued arm only clients that sent delivery:'queue-if-active'
ever receive. Whole-list queuedMessages fields ride the subscribe events and
history pages; Stop gains withdrawQueued with the withdrawn bodies in its
result; clear's result carries withdrawn drafts too. Both classifiers treat
queued as accepted/spent. agent-session.queued-messages.v1 is defined but
deliberately NOT advertised: the rollout prerequisites (Claude fold receipt,
integrated Codex steer matrix) are not in this host.

* feat(native-chat): queue a capable mid-turn send as a draft, drain it at turn end, and let Stop and clear return its text

A send carrying delivery:'queue-if-active' while the session owes work — or
behind an actionable backlog — becomes a host-held draft instead of a
submission. A serialized drain woken by journal commits, draft mutations and
conversation opens re-derives its gates from live facts (streamed-event
barrier first, backlog never a gate) and converts the oldest actionable
draft through the exactly-once consume; from that instant today's delivery
pipeline runs unchanged. Stop pauses the withdrawable frontier at the stop
step (a process-level pause set that survives handle eviction and, via the
per-process host instance, restarts), then withdraws it with the text in the
result for capable clients; /clear does the same for the superseded source.
The draft list publishes whole per emit with identity dedup, rides only the
final catch-up page, and attaches to history pages. queuedMessageSend
overrides queue policy only; queuedMessageDelete hands the body back.
Replays for all of it answer from op-stamped tombstone receipts.

* test(native-chat): pin mid-turn queueing against the real host

Accept (working/backlog/text-only/budget/replay), the one-per-settle drain,
returned cards with N1 overtake and the N4 re-send loop, Stop withdraw with
tombstone replays, the process-level pause across evict/reopen, Delete
receipts, /clear returning the withdrawn text, and publication (hydration,
unchanged-cursor insert, same-frame consume, identity dedup).

* test(native-chat): read the queued receipt ids before the wait closures

* chore(native-chat): SAFETY rationales on the sqlite row casts and a cast-free mobile narrowing

* fix(native-chat): queued-draft bookkeeping never costs a publish, an open, a clear or a history read

- Cache the draft list per draft-table revision. The drain re-checks on every
  journal publish, so each streamed delta was running a SELECT and parsing
  every draft body the handle had ever written (tombstones included).
- Open-time repair/prune failures are reported and skipped; they no longer
  fail opening the chat.
- /clear on a source with no drafts answers exactly as before: no empty
  `withdrawnQueued`, no empty write transaction, no extra publish. A draft read
  failure after the committed clear no longer turns it into a refusal.
- History pages read drafts through the same guarded reader as subscribers.
- Publication moves to its own module; the held-draft rule lives with the
  pause state; one pending-prompt check; drop an export nothing calls.
- Tests: restart-held drafts, pre-consume failure pause + Send retry, failed
  open repair, clear with no drafts.

* fix(native-chat): a Stop that withdraws a consumed draft's send gives its text back

A queued draft converted into a submission leaves the sender's outbox, so when
a Stop withdrew that submission before the agent received it, the text had no
holder: the draft stayed `dispatched` forever and nothing restored it.

- The returned-card rule now follows every effective `rejected` settlement of
  a consumed draft's submission, a Stop's withdrawal included, with the
  withdrawal reason stored as the fact (`dispatchWasWithdrawn`). The writer
  hook and the open-time repair share the rule, so no rejected submission can
  leave its draft `dispatched`.
- A capable Stop withdraws the cards it returned itself along with its
  frontier, stamped with its caller-scoped key: the text comes back once in
  `withdrawnQueued` and replays from the tombstone. An old client's Stop
  leaves a returned card.
- Stop's draft steps move to structured-agent-session-queued-stop.ts.
- Tests: Stop between consume and the agent's receipt for both client kinds,
  its replay, a crash after the withdrawal, restart in the window, and the
  repair of a hookless withdrawal.

* perf(native-chat): the queued-draft drain takes no serialized step while the agent works

The drain was woken by every journal publish and, with a draft waiting, queued
a serialized step (streamed-event flush included) per publish, only to find the
session still working. During a streamed turn that is one step per delta,
contending with Stop and every other mutation for the session's queue.

The pre-check now also skips while the session is working. Whatever ends the
work is itself a commit that schedules again, and the step still re-reads every
gate after its flush, so no wake is lost.

- Test: queued sends during a turn take no drain step; settling the turn drains.

* fix(native-chat): a clear withdraws queued text only for a caller that can take it back; paused reasons are markers

An older client running /clear had its source's waiting and returned drafts
withdrawn and their text returned in a `withdrawnQueued` field it does not
read, so the text was lost. Clear now mirrors Stop: `withdrawQueued: true` on
`agentSession.conversationCommand` (strict params, sent only when the
queued-messages capability is advertised) withdraws the drafts and returns
their text once, replaying from the tombstones. Without it the source keeps
its cards: the supersession fence already blocks the drain, and Delete still
hands the text back.

A paused card's reason was host-authored English on the wire. It is now a
typed marker (`send_failed`) the client localizes, like `returnedReason`; a
client treats an unknown marker as a plain pause.

- Tests: an old client's clear leaves the cards and its replay stays
  field-free, then Delete returns the text; a capable clear returns the text
  once and replays it; the paused marker.

* fix(native-chat): a draft pause that commits no journal row still reaches live subscribers

A pause writes no journal row, so it reaches subscribers only on the next
publish. Two pauses had none behind them: the drain's pre-consume failure
(the session is idle by then, so nothing else commits) and an old client's
Stop that interrupted nothing. A live card kept reading as waiting, with no
failure marker, until some unrelated commit arrived.

The drain now publishes after pausing a draft it failed to convert, and an
old client's Stop publishes when it paused a frontier.

- Tests: a failed conversion and an idle old-client Stop each reach a live
  subscriber as a paused card; both fail without the fix.

* fix(native-chat): a failed clear wakes the queued drain, a failed Stop withdrawal still publishes its pause

A conversation command can settle on the record alone (a retried clear that
fails), so drafts held behind its prepared phase waited for an unrelated
journal commit; the command controller now re-derives the drain when any
command finishes. A capable Stop whose withdrawal write failed never
published the pause it set, and a publish failure after a committed
withdrawal (Stop or clear) dropped the bodies from the answer; publishing now
happens outside the withdrawal and can no longer discard its result. Tests
reset the process-level pause set between cases: operation ids repeat per
test, so a shuffled order held later tests' drafts.

* refactor(native-chat): the draft store notifies through the journal's commit listener, the hold is a stored row fact, and one typed gate decides every queue hold

R1: every standalone draft-table transaction that changed rows (insert,
withdraw, hold, open-time repair) fires the journal's own commit listener
after COMMIT, so a draft or hold change publishes and wakes the drain through
the same path a journal row does — no call site can forget. All hand-written
publish/wake plumbing for draft changes is deleted; wakeQueuedDrain survives
only as the record-input wake (a conversation command can settle on the
record alone).

R2: the process-level pause set becomes a hold_reason column on the draft row
(pre-ship, so no migration): holds survive eviction and restart, keep their
send-failed marker across restarts, die with the session's journal, and are
cleared by consume and withdraw in their own UPDATE. The host-instance
derivation stays the one restart mechanism.

R3: one typed structuredQueueHold (blocked | command | prompt | working)
consumed by admission, the drain step and Send-now, with each caller's
override set written beside it. A capable send during a late-result /compact
now queues instead of being refused (PLAN §3.1); the dead prepared-command
branches and the drain's duplicated gate list are gone. prompt outranks
working so Send-now's one override cannot swallow it.

R4: one isUnsettledQueuedMessage predicate for the withdrawable/budget
filters.

Loop 4: a replayed send whose draft was refused answers with the returned
card, never the rejected submission, so the text cannot render twice. Rewind
completion was verified to publish after the record clears (the rewind path's
own publish; the open path's recovery precedes the open snapshot).

* fix(native-chat): a Stop with no drafts writes nothing, and a failed hold still lets a capable Stop withdraw

The stored hold turned Stop's in-memory pause into a draft-table write, so
every Stop (drafts or not, capability advertised or not) opened a BEGIN
IMMEDIATE/COMMIT. An empty hold now returns before the serialized write.

A hold that threw also emptied the frontier, so a capable Stop withdrew only
returned cards and left the waiting drafts unheld to auto-send after the
interrupt. The frontier is read once and survives a failed hold.

* fix(native-chat): a capable Stop with no drafts writes nothing

The empty-hold guard from the previous fix did not reach withdraw, so every
capable Stop still opened a write transaction after the interrupt, and a
closed handle turned its empty answer into a missing field. The draft store
now answers an empty withdraw without a transaction, for every caller.

* refactor(native-chat): Stop and /clear never withdraw queued drafts; no text rides the wire back

Adopt the host-owned-queue model end to end: a Stop holds the waiting
frontier ('stopped') for EVERY client and interrupts — the cards stay
published as paused, Send-now overrides per card, and the pause dies when
the user next starts a turn (an ordinary dispatched send lifts 'stopped'
holds in the same serialized step; 'send_failed' holds still need their
explicit Send). /clear carries the source's unsettled drafts to the
replacement session as born-held rows — identical for every client
version — then tombstones the source. Delete answers with no body: the
card leaving the published list is the outcome.

Removed (never shipped; the capability was dark and unadvertised, so no
wire compatibility is affected): CancelParams.withdrawQueued and its
refine, ConversationCommandParams.withdrawQueued,
CancelResult.withdrawnQueued, ConversationCommandResult.withdrawnQueued,
AgentSessionWithdrawnQueuedMessage, the Delete result body,
settleStopQueuedWithdrawal and the cancel finisher,
withdrawClearedSourceQueuedMessages, replayWithdrawnQueuedMessages, and
cancelPlan's tombstone replay. This also removes the defect where a
withdrawal took every row regardless of which client sent it (a phone
Stop pulled desktop-typed text): nothing moves text anymore, so a Stop
from one client can never relocate another client's drafts.

Hold and carry writes are bookkeeping: a failure is logged and never
gates the interrupt or the clear.

* feat(native-chat): a restart hold lifts like a Stop's, and paused cards say why

The user's next dispatched send lifts every stop-shaped hold in one
UPDATE: stored 'stopped' rows, and restart-held rows (host_instance
mismatch), which are adopted into the running instance — the same fact
the derivation reads, so no second copy of the hold exists. 'send_failed'
still requires its explicit Send. Publication now marks stop/restart
holds with pausedReason 'stopped' (an additive optional value on a dark
capability), so clients can caption them "sends after your next
message" and keep "couldn't send" for 'send_failed'.

* fix(native-chat): only a client's own send lifts a Stop's queue pause

The lift ran for every accepted host send, so orchestration mail, a
restart continuation and a launch prompt released drafts the user had
stopped (and adopted restart-held rows into the running instance). The
client-facing agentSession.send RPC now marks its sends as the user's
own; host-internal senders leave the pause alone. Also drops comments
still describing the withdrawn return-text rule.

* fix(native-chat): a Stop's queue pause lifts when the user's send starts its turn

The pause lifted as soon as the host accepted a user send, so a send the
provider then refused (a failed child start, a refused turn/start) had
already released the stopped drafts into the same failure. The host now
remembers a client's own send, in memory, until the provider answers it:
acceptance lifts the stop-shaped holds, a refusal forgets it with the
holds intact, and a later Stop supersedes it. Nothing is persisted, so a
restart between the send and its turn start leaves the cards held for the
user's next send rather than sending them unasked.

* fix(native-chat): a consumed draft's turn starting lifts a Stop's queue pause

Drafts are only ever a client's own sends, so a drained draft or a
Send-now is a user send for the pause: its submission joins the same
in-memory set a direct send uses, and the provider accepting it lifts the
stop-shaped holds. Before, a message typed while a stopped turn wound
down drained as a draft and left the older stopped cards held, so their
"sends after your next message" caption was false. A refused consumption
lifts nothing, a later Stop still clears the set, and orchestration mail
and restart continuations still never lift.

* fix(native-chat): queue a capable send behind a /compact and re-scope /clear's carried drafts

- A text send with queue-if-active during a /compact in flight is admitted on the
  compact's side lane as a held draft instead of being refused; it may only become
  a draft, so one the gate no longer holds is refused rather than dispatched.
- Drafts /clear carries to the replacement are fingerprinted for the replacement
  session, so the provider's echo folds into the sent bubble.
- The in-memory set of user sends awaiting their turn is capped; sends settling
  unknown no longer grow it without bound.
- Correct the userSend comment: the renderer's launch prompt goes through the
  client RPC and does set it.

* fix(native-chat): a returned queued card carries the typed rejection fact, like a rejected submission

A consumed draft the agent never ran comes back as a returned card. The card
kept only the rejection's sentence, while its submission now also records the
typed fact a client classifies from. A host-restart rejection's sentence
carries no legacy marker, so such a card could not be told apart from a
provider's refusal.

The draft table stores the submission's fact next to its reason
(`returned_rejection`, written by the same settlement that sets the reason,
and read back with the reducer's own fact reader), and the card publishes it
as `returnedRejection`. Both are overwritten on every return, so a re-sent
card never keeps an earlier refusal's fact, and a /clear carry inserts a plain
held draft with neither.

Retention moves to queued-message-retention.ts to keep the table module
within max-lines.

* fix(native-chat): fit the queue to main's typed rejections and compaction result

Main (#23026) dropped the disposition's fresh-id retry field, gives a
rejected dispatch a typed sentence plus fact, and types /compact's result.
The queued-draft disposition and the queue tests now use those shapes.

* fix(native-chat): draft bookkeeping can never roll back the journal row it rides

The queued-draft returned transition runs inside every journal append's
transaction. A throw there (a draft table an earlier build created without the
returned_rejection column) rolled back the journal's own rejection row, so a
Stop, a failed start or a provider refusal could not be recorded. The standing
hook now runs in its own savepoint: its failure is logged and rolls back alone,
and the open-time repair re-derives the missed transition from the committed
row. The draft table also gains any missing nullable column at open.

* fix(native-chat): a draft a Stop or restart took back waits again instead of blocking the queue

Cards A, B and C wait; the turn ends and the drain consumes A, but the agent
has not taken it yet. A Stop then pauses B and C and withdraws A's submission,
which made A a returned card. The user's next send lifted B and C, yet a
returned card blocks everything behind it, so B and C never sent although they
read "sends after your next message". A restart or close before hand-over did
the same.

Nobody failed the user there, so the draft now goes back to waiting at its own
position, under the hold that same event put on the drafts behind it: a Stop's
'stopped', or no stored hold after a restart, whose hold derives from the host
instance. It carries no refusal, and records its spent submission id in
consumed_as, so its next consume (the drain, or Send on the card) mints a fresh
id through the same path a returned card's re-send uses. Provider refusals and
other failures still return the card. The live settlement hook and the
open-time repair share one decision. After a Stop and the user's next turn,
A drains first, then B, then C, one per turn.

* fix(native-chat): Delete and Send on a queued card answer at once during a /compact

A /compact holds the chat's serialized lane for its whole provider call, and
the queued-card Delete and Send ran on that lane, so both hung until the
compaction finished. They now run on the side lane a draft-only send already
uses while a compaction is in flight: Delete completes at once, and Send
reaches its readable "wait for the conversation operation" refusal at once.
The drain stays on the main lane and keeps its command hold, so nothing sends
until the compaction settles.

* fix(native-chat): a re-sent returned card drops the refusal it came back with

Re-consuming a returned card left returned_reason and returned_rejection on the
now-dispatched row, so the row described a refusal that no longer applied. The
consume clears both in the same update that moves the card to dispatched.

* perf(native-chat): the queue gate reads pending prompts without rendering the journal

The prompt check ran on every send admission and drain step, and read
journal.snapshot(), which copies and sorts every item in the chat. It now walks
the reduced items in place with journal.visitItems; the answer is the same,
since the snapshot only sorts those items.

* fix(native-chat): a Stop that fails leaves the queued cards as it found them

Stop holds the waiting cards before it withdraws queued sends and interrupts
the agent. When a later step threw or the Stop was refused, the cards stayed
paused ("sends after your next message") although a failed Stop is meant to
change nothing. A failed Stop now undoes exactly what it added: each card it
held gets back the hold it replaced, a consumed card its withdrawal sent back
to waiting is released, and the user sends it had set aside can again lift the
pause. Holds an earlier Stop or a restart put on the cards stay.

The hold SQL moves to its own module, and the draft store's standalone
transactions share one helper.

* docs(native-chat): confirmed cancellation is no longer a queue rollout prerequisite

Stop withdrawing queued sends with a typed cancellation landed on main with
#23026. The comment gating the queued-messages capability now lists only what
remains: the Codex steer matrix (#21062), the Claude fold receipt, turn-owner
bars, and the desktop and phone clients.

* docs(native-chat): the Claude fold receipt and turn-owner bars have landed; Codex steer and the clients remain

* fix(native-chat): Send on a queued card during a /compact is refused before it takes a lane

Send-now chose its lane once, at entry. During a /compact it took the side
lane, where it could wait behind a Stop, then run after the compaction had
settled and append a real submission unserialized against the main lane.
While a compaction is in flight, Send-now is now answered with the "wait for
the conversation operation" refusal before entering any lane, and otherwise it
runs on the main lane. Only Delete keeps the side lane, whose compare-and-set
withdrawal is safe on either.

* fix(native-chat): a Stop that fails after reaching the agent keeps the queue paused

A failed Stop undid its queue holds whenever it threw, including after the
interrupt had already gone to the provider (a status-note write failing after
cancelTurn, or after stopping a starting agent). The turn could be stopped
while the cards drained as if no Stop was pressed. The Stop now marks the step
that reaches the provider, and undoes its holds only when it failed before
that. A Stop the agent refused answers ok and keeps its holds; the comment no
longer claims otherwise.

* fix(native-chat): a skipped draft settlement heals on the next drain step, not only at reopen

The draft settlement rides each journal append as bookkeeping, and a failure
there is logged and skipped. Only the open-time repair re-derived it, so a
consumed draft whose submission was rejected stayed dispatched (invisible, and
blocking nothing it should) until the chat reopened. The re-derivation is now
its own function, shared by the open-time repair and the drain: whenever a
dispatched draft's submission is already rejected, the drain step applies the
owed settlement first.

* fix(native-chat): one id is never recorded as a submission twice

A second submission row under an id the journal already holds replaces the
submission with a fresh pending one, so a rejected message could be handed
over again under its own id. Send on a queued card could do exactly that: if
the host died after it consumed the card under the operation's id but before
its answer settled, the rerun consumed again under the same id.

The journal now refuses a submission under an id it already records, so no id
is delivered twice whatever the caller does. And a Send-now rerun that finds
the card consumed under its own operation id answers with that submission
instead of consuming again.

* fix(native-chat): a waiting draft whose first send the agent echoed is withdrawn, never resent

A consumed draft goes back to waiting when its submission is rejected as never
delivered (a Stop's withdrawal, a restart, a close), and then sends again
automatically. That rests on the "never delivered" claim. If the provider then
echoes that message, the first delivery happened, and the automatic resend
would give the agent the same message twice.

The reducer already keeps such an echo apart, since a rejected submission may
not claim it, so the draft store reads it from the appended row itself: a
provider echo of a user message that no live submission claims, matching a
waiting draft whose spent submission is rejected, withdraws that draft the way
a Delete would. The echo-claiming rule is split out of the reducer's aliasing
so both read the same decision, and the per-row draft hook moves beside the
settlement re-derivation.

* feat(native-chat): a submission names the queued draft it hands off

Clients told a queued card's hand-off apart from other sends by comparing the
draft's id with the submission's id. That holds only for a draft's first
hand-off: a re-send, or a draft that goes back to waiting and drains again,
goes out under a fresh id, and the clients showed the card and the sent
message together, or restored text the host still held.

Every submission the host creates by handing off a draft now carries
queuedMessageId, the draft's id. It is written on the submission's journal row
as an optional key (older readers keep it and ignore it), carried by the
reducer, listed in the published submission schema (which otherwise strips
it), and stamped where the row is built from the consume itself, so no
hand-off path can leave it off; a caller naming a different draft is refused.
A direct send names none. The queued-messages capability comment makes the
link part of v1.

* refactor(native-chat): every queued draft goes out under a fresh submission id

A draft's first hand-off reused the draft's own id as the submission id, so
comparing a draft id with a submission id looked right in every first-send test
and failed only on a re-send or a requeued draft. Every hand-off now uses a
fresh id (the drain mints one; Send on a card uses its operation's id), so id
equality is never true and a reader must use the submission's queuedMessageId.

The host gets simpler: queuedMessageNeedsFreshSubmissionId is gone, consumed_as
is set on every dispatched row and cleared when a withdrawal sends the draft
back to waiting (its spent submissions stay findable by their link), the
consume refuses the draft's own id, and the consumedAs ?? messageId fallbacks
collapse. The delivered-echo check finds spent hand-offs by link.

A send this host queued, asked again (a lost answer's replay, or a rerun the
operation ledger no longer covers), answers from its draft and then from the
hand-off that names it, through one function. The rerun path used to be kept
from sending twice only because a submission sat under the send's own id;
with fresh ids that guard is now explicit. A Send-now rerun recognises its own
consume by the link instead of consumed_as.

* fix(native-chat): an echo withdraws a draft only if its rejected hand-off reached the agent

The delivered-echo rule withdrew a waiting draft when a provider echo matched
any rejected hand-off of it, including one a Stop rejected before it was ever
handed over. That hand-off is provably unwritten, so a matching unclaimed echo
is some other message, and the rule silently deleted the card. Only a hand-off
that was handed over and then rejected as never delivered can be disproved by
an echo now.

* fix(native-chat): a skipped echo withdrawal is re-derived before the draft can send again

The delivered-echo withdrawal rides each journal append as bookkeeping, and a
skipped hook left the draft waiting, so it later sent the same message a
second time. Nothing re-derived it. The draft store now also withdraws, in its
owed-settlement pass, each waiting draft that an echo already in the journal
proves delivered: an unclaimed provider user message (still stored under its
own id), carrying the draft's payload, appended after a hand-off that was
handed over and rejected. The live hook and the re-derivation share one
predicate. The pass runs at open and in the drain step, right before a draft
would send; it reads every item, so it never runs per streamed row.

* fix(native-chat): a rolled-back journal append leaves no draft state cached

The draft store caches its row list by revision. The per-row hook read that
list eagerly inside the append's transaction, after the consume in the same
transaction had already written and bumped the revision, so a failed COMMIT
left the cache showing a hand-off that never happened. The hook now reads the
drafts only once a row holds an unclaimed echo, and any rollback of a journal
append or of its bookkeeping savepoint invalidates the cache, so no other read
inside the transaction can leave it stale either.

* fix(native-chat): a replay of a deleted queued card answers withdrawn, not refused

Once a deleted card's tombstone is pruned, a replay of the send that queued it
found the draft through its last hand-off. When that hand-off had been
rejected (the card came back, and the user then deleted it), the replay
answered with the rejected submission, which clients show as a failed send
with a Retry. Only a withdrawn row is pruned while its last hand-off stands
rejected, so the replay now answers queued, withdrawn.

* refactor(native-chat): name the queue's pause-lift for what it releases

* chore(native-chat): one import of the mutation helpers

* feat(native-chat): a Stop pauses the whole queue, derived from the journal, with an explicit Resume

After a Stop, each waiting card was held on its own row ('stopped'), lifted
when the host saw, in memory, that a user send made after the Stop had its
turn accepted. The cards read "sends after your next message" one by one,
there was no way to resume the queue without sending something, and the
in-memory record of user sends was lost on a restart or eviction.

The pause is now the queue's, and derived rather than stored as a flag:
- 'stopped': the user's last Stop took effect at a recorded journal position
  and no turn a person asked for has started since. "A person asked for it"
  is the new `origin: 'client'` on the submission row (a send over the client
  send RPC, or a card they sent now); orchestration mail, a restart
  continuation, a host-sent launch prompt and the queue's own drain record
  `host` and never lift it.
- 'restarted': a waiting card was written by another host process and no
  person's turn has started since this conversation opened.
Resume (`agentSession.queuedMessagesResume`) lifts either. Send-now sends one
card; the rest stay paused until that card's turn starts, which is a person's
turn like any other.

The journal's row kinds are closed (an older build truncates a journal at a
row kind it does not know), so the one event the journal cannot carry, where
the Stop took effect, is recorded beside the drafts in `queued_message_pauses`;
everything after it is read from the journal. A Stop records it only once it
takes effect (after withdrawing queued sends, as it reaches the agent), so a
Stop that fails first leaves nothing to undo, and the per-row hold, its undo
and `userSendsAwaitingTurn` are gone. A card keeps a hold of its own only when
its conversion failed ('send_failed').

The pause is published once, as `queuePause` beside `queuedMessages`, on live
frames, catch-up and history. A /clear starts its replacement paused, as after
a Stop, since the carried cards were written for the context it discarded.

* feat(native-chat): a /clear's replacement queue reads paused because of the clear, not an interrupt

The replacement's pause was recorded as 'stopped', which clients show as
"Queue paused because you interrupted" although the user cleared the chat.
It is now its own reason, 'cleared', on queuePause.reason
('stopped' | 'restarted' | 'cleared'). It lifts and resumes exactly like a
Stop's: through Resume, or the user's next turn starting on the replacement.

* fix(native-chat): a queue pause covers only the cards it paused

A Stop recorded its pause fact even when the queue had no cards, and the fact
outlived the cards it did pause. The published list hid a pause over no cards,
but the drain still treated the queue as paused, so a card typed much later —
during an orchestration-mail turn, or a correction typed before the stopped
turn ended — sat under "paused because you interrupted" with no Stop of its
own.

A Stop now records its pause only if the queue holds a card when the Stop takes
effect (the hand-offs its withdrawal sent back included). The fact is retired
in the same transaction as the Delete, consume or withdrawal that empties the
queue, never from an async publish. A /clear's carry now lands each card with
its 'cleared' pause in one transaction, so a failed insert leaves no pause over
an empty replacement.

* perf(native-chat): the queue's pause reads the latest person's turn in O(1)

The pause is derived on every publish, per subscriber, and each derivation
copied and scanned every submission to find a person's accepted turn after the
Stop. The reducer now keeps that fact as it folds rows: the submission row of
the latest accepted turn whose origin is `client`. The Stop's and the
restart's lift both read it directly.

* fix(native-chat): a card handed off after a restart belongs to the process that sent it

A draft's host_instance was only ever the process that first wrote it (or
adopted it while waiting). A returned card from before a restart, sent again
in this process and then withdrawn back to waiting, still carried the old
process, so it raised a 'restarted' pause although no restart happened since
it was sent. Every hand-off (the drain, Send on a card) now stamps the
handing-off process on the draft in the consume's own update.

* fix(native-chat): a queue pause shows only while Resume would send something

After a Stop whose only remaining card was a returned one, or after a restart
with only a card held by its own failed send, the queue published a pause with
a Resume that could send nothing: a returned card waits for the user anyway,
and a held one for its own Send. The pause is now published, recorded by a
Stop, and kept only over a card it can hold back — waiting, with no hold of
its own. The fact is retired in the same transaction as the write that removes
the last such card, a hold or a refusal included.

The publication's dedup also compared only the pause's reason, so a pause
appearing or clearing with no readable reason could read as unchanged; it now
compares presence first.

* fix(native-chat): a queue pause counts only cards Resume would actually send

A waiting card behind a returned one is blocked until the user acts on the
returned card — the drain never sends past it — so a pause over only such
cards still offered a Resume that sent nothing. The rule for "a card Resume
would send" is now one function: waiting, no hold of its own, and not behind a
returned card. The publication, a Stop's record and the fact's retirement all
read it; retirement reads the rows in position order inside the same
transaction as the write that took the last such card.

* fix(native-chat): a returned card that blocks the paused cards hides the pause but keeps it

The last change retired a Stop's pause as soon as a returned card blocked every
paused card. Deleting that returned card then sent the cards behind it at once,
with no Resume — not what the user asked for.

The two rules are now separate. The pause is KEPT (recorded by a Stop, retired
in the same transaction as the write that takes the last one) while any waiting
card with no hold of its own exists, wherever it sits. It is PUBLISHED only
while such a card is not behind a returned one, so the header never offers a
Resume that sends nothing. Deleting the blocking card shows the pause again,
and the cards behind it wait for Resume or the user's next turn.

* fix(native-chat): a Stop pauses a card its withdrawal sent back even when that settlement was skipped

The Stop checked the draft table for a card to pause. When the per-row hook
that settles a withdrawn hand-off was skipped, that card was still
'dispatched', so the Stop recorded no pause; the drain later healed it back to
waiting and sent it, although the user had pressed Stop. Retirement had the
same blind spot and could drop a pause while such a card was owed.

What a pause holds back is now one predicate, judged inside the transaction
that records or retires it: a waiting card with no hold of its own (one SQL
EXISTS), or a dispatched card whose consumed submission was rejected with a
settlement back to waiting (read against the journal's submissions). The Stop
first runs the owed settlement, as the drain does; if that fails, the owed
card still counts, so the pause is recorded rather than skipped. recordPause
now checks inside its own transaction and returns whether it recorded, and any
draft-table write (and the per-row hook, the consume and the open-time
repair) retires a pause that no longer holds anything back.

* test(native-chat): pin the per-row hook's pause retirement; skip the judgement when no pause exists

The retirement test recorded its second pause over a queue with nothing to hold
back, so the recording returned false and the "retired" assertion proved
nothing; ablating the per-row hook's retirement passed every test. The hold
case now asserts the pause was recorded, and a new test has a delivered echo,
through the per-row hook, withdraw the last card a recorded pause holds back.

Retirement runs on every appended journal row, so it now checks the pause row
by key first and judges nothing when no pause is recorded. Two comments were
brought in line with the owed-hand-off rule and rewrapped.

* test(native-chat): match main's append and dispatch shapes in the queue tests

* fix(native-chat): read a compaction's settled submission through the send-result union

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-29 15:22:40 -07:00
Brennan BensonandClaude d60f999f94 fix(native-chat): turn facts come from the turn record, and /compact is a message the chat sends (#23059)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* fix(native-chat): the conversation outlives its agent

Opening a chat no longer starts its agent. A conversation is reached through one host
accessor that opens its journal at rest, and a send is what starts the agent, through
the delivery loop. One idle sweep, every five minutes, stops an agent that has been
quiet for thirty minutes and owes no work, then drops an open journal handle that is
only a cache. Its record, tab, status row and readers stay.

- hold and release are no-ops; hold still builds the host for shipped mobile builds.
- The holders, the holds, the release clock and the exit respawn are deleted.
- Options, the model list, the goal and the context meter answer at rest; a model pick
  at rest is recorded as intent for the next start.
- Compact, rewind, clear and goal changes start the agent first. A send does too when
  a rewind is still in doubt after the conversation opens.
- Orchestration routes mail and group addresses on ownership (the record plus the chat
  tab), not on whether the process runs. An open dispatch keeps its worker running.
- The restart continuation is a send; Resume all holds each slot until the message is
  handed over or rejected.
- A read error never replaces a loaded transcript, and shows the host's own words.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* fix(native-chat): a request that failed reads as failed

A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.

The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): a verdict change republishes the mobile status projection

* refactor(native-chat): the store's retention trigger keeps its flag compare

A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.

* test(native-chat): a user message the provider journaled keeps its session listed

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a restart offer ends when the chat's agent starts again

The offer used to end only when the chat's newest user message changed,
because opening a chat started its agent and that start could not be told
apart from real activity. Opening a chat starts nothing now, so the host
reads the fact it already publishes: a chat's status row goes from not
host-owned to host-owned exactly when its agent is started. At that edge the
offer and any failure record for the chat are withdrawn, unless the start is
a resume action's own (its continuation is the oldest undelivered message).

A continuation and a message racing to be first are decided at acceptance:
the continuation is refused, quietly and with nothing filed, when any other
message was accepted since the restart. A failed continuation start leaves
the offer retryable, and each resume action sends its own message id.

Deleted: the newest-user-message comparison, its journal reader, the
continuation filter, and the failure ledger's own "answered by the chat"
check. The marker still carries its message id for one release, so the
previous build can read it.

* fix(runtime): end a transcript stream when its client unsubscribes

Desktop: the IPC subscription controller was dropped as soon as the streaming
handler returned, which for most streams is right after it binds. A later
runtime:unsubscribe then found nothing to abort, so the host kept the subscriber
and derived and sent every publish to a channel no one listened to. The controller
now lives until the renderer unsubscribes, resubscribes the same id, or goes away.

Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe
with the stream's frame id, so the host ends that subscriber and leaves a sibling
stream on the same socket running. The direct path now passes the frame id the relay
path already passed.

* fix(native-chat): a late provider-session update keeps a failed recovery record failed

A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* test(native-chat): the terminal-bell check asserts the renamed verdict field

The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.

* fix(native-chat): a failed turn ranks like a completion for attention

Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.

The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.

* fix(native-chat): one fact ends a restart offer: the chat moved on since the restart

The offer is live while no other message has been accepted in the chat since the
restart and its agent has not proved a start since. The offer list, the resume's
reservation check and the continuation's acceptance check all read that one fact,
so a message whose start then failed withdraws the offer too, and a stale click
finds nothing to act on.

The fact is read off the conversation's open handle, which the restart closed, so
it is retired durably whenever it may have changed: a message accepted, a start
proven. A close and reopen within the same run therefore cannot bring the offer
back. A continuation rejected before it reached the agent does not count, so a
retry after a failed start still runs.

Deleted: the quit-time gate on withdrawal, which changed nothing because the
withdrawal and the quit's own offer write share one queue; the per-action
"withdrawn" flag and the separate acceptance check it paired with.

* test(native-chat): an older build reads the restart offer this build records

The offer lives in a file the previous release reads after a downgrade. Pin that
against the pinned release's own capsule, and run the lane when the marker or the
capsule changes.

* fix(native-chat): read a restart offer against where the journal stood when it was taken

"Since the restart" was read off the conversation's open handle, which the idle
sweep closes: after a reopen, a message the user had already sent looked older
than the handle and the withdrawn offer came back.

The offer now records the journal position (epoch and sequence) at the moment
it is taken, and a message accepted after that position, or a journal on another
epoch, means the chat moved on. That is derived from the journal, so it holds
across any number of closes and reopens. An older build's offer has no position;
only a start withdraws it. Because the message half is now durable, the offer is
no longer rewritten in the recovery file on every accepted message; a proven
start still writes it, since only the host that saw the start knows of it.

* test(native-chat): wait for the listing's retire write before reading the recovery file

* refactor(native-chat): every journal row states which turn it belongs to

Rows gain a turn scope stated by the write that creates them: the open root
turn, or the conversation. A queued message takes its scope from its handover.
Rows stored before scopes existed are placed on replay by the root turn open
when they were created, so no persisted state is needed for them. Rewind keeps
each retained row's scope and producer, so a subagent's row stays its own.

* fix(native-chat): keep the terminal-backed chat's read error over its local echoes

Messages winning over a read error is right for the structured chat, whose read retries and whose
messages came from the transcript. The terminal-backed view assembles its list from local echoes
too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no
error. Only the structured pane now keeps messages over an error.

* fix(native-chat): a start retries the exit settlement a failed journal write left owed

An agent exit whose journal settlement write failed releases the lease latched until a retry lands.
Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried
it before the next app launch, and every send was refused. The start the send needs now runs the
retry first, where the attach would.

* fix(native-chat): a failed main agent reads failed while its subagents still work

The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.

Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.

worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.

* perf(native-chat): answer the owner check without opening the chat

Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the
answer comes from the session record alone. Reaching it through the accessor opened each resting
chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it
open for the idle window. It now checks the record and the adapter's support, as before this series,
and opens nothing.

* fix(native-chat): a read waiting on the session lock opens nothing once quit began

The accessor checked for quit before queueing the open, so a read queued behind a session task ran
its open after teardown had begun and indexed a journal no teardown step would close. The check now
runs at the open itself.

* test(native-chat): pin stated turn scopes, the upcast of unscoped rows, and rewind attribution

* fix(native-chat): /compact is a message the chat sends, run as a turn of its own

The conversation command RPC now accepts /compact into the queue like any
send and answers once it is handed over. The delivery loop opens the command's
own turn, starts the provider on it, and waits for the provider's end off the
session's queue, so messages typed meanwhile are held and delivered after it,
even when it fails. It settles by re-reading the journal: a child that died
meanwhile already wrote the verdict. Stop ends the command at once. The 180 s
completion window, the unconfirmed row and the recovery of an older build's
compaction record are gone; that record no longer gates anything. On Codex the
provider turn the command opens is claimed into the command's turn.

* fix(native-chat): read a failed resume's chat before calling it retryable

Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the
restart, read from its journal. The failure list read it only for a chat already open, so once the
idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did
nothing. The list now opens the failed chats first, as the offer list does.

* test(native-chat): type the provider event sink the settlement test reaches for

* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it

The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.

* fix(native-chat): say the structured read keeps trying only where it does

The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an
untranslated fallback whenever the read error had no text, and the empty state prefers any message.
The view state now leaves the message out, so the structured pane shows that line and the
terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an
error frame, so it no longer makes the claim.

* fix(native-chat): rows group under the turn their record names, not the one above them

Each row's turn is the turn its stated scope names, anchored on the entry
that opened it, or on the turn itself when the provider opened it unasked.
So /compact groups its own rows and the previous turn is untouched, a message
typed into a running turn joins it, and a provider-resumed turn folds under
its own Worked-for. A row reporting how a turn ended, an error or the
compaction separator, never folds. Desktop and mobile read the same keys; a
host that states no scope keeps today's positional grouping.

* test(native-chat): await the send's settlement instead of polling for the start

The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a
loaded machine outran. They now await the host's own settlement of the message.

* docs(native-chat): the status-store listing rule names provider-journaled user messages

* fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations

The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than
a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now
dated by the resume action.

Telling a rejected continuation from the user's own message read the operation ledger, whose rows
expire after about a day; after that a failed resume stopped being retryable. The offer now
records the continuation each action sends on its own capsule entry, bounded to the newest 16, so
the ids end with the offer. The ledger read is deleted.

* fix(native-chat): a /compact is not a request the sidebar, notifications or restart resume report

The sidebar's prompt, preview, verdict and instant, the turn-completion feed,
and the restart-resume marker read past a conversation command and its turn to
the last real request, so a /compact neither notifies nor re-dates the row,
and a command in flight is never offered as work to resume. An older client
shown a command's turn in the legacy form names the session's own agent.

* fix(orchestration): route no mail to a structured worker its orchestration released

A structured worker is routed on ownership, and a resting worker's lease is released, so ownership
held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found
at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one
restarted its agent. Routing now also reads the orchestration's own resource row: once it is
released, direct mail, group addressing and worker-show's addressable answer drop the worker, as
they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored.

* fix(native-chat): a failed retry names the user's prompt, not Orca's continuation

A resume's continuation is written to the chat before its start, so after a failed attempt the chat's
newest user message is that rejected continuation. A second failure then showed Orca's own restart
text as the chat's prompt. A retry now keeps the prompt its first failure named.

* test(native-chat): pin what a conversation command's admission refuses at rest and at handover

* test(native-chat): tests merged from the base state which turn their rows belong to

* fix(native-chat): a refused send notifies failed through the completion feed

The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.

* fix(orchestration): read the released row optionally, as the authority does

worker-show's observation called the row lookup directly, which a runtime double without it threw on
and failed the structured tab-retirement release.

* chore(native-chat): one import per module and no unexplained casts in the turn-scope changes

* test(claude): pin which turn a Claude row joins, including a subagent's after the turn ends

* fix(native-chat): the status bar drops a restart offer the chat moved on from

The renderer re-read the host's restart offer only when a failed chat showed activity, so after a
message withdrew a pending offer the host answered no chats while the status bar kept counting one,
and clicking it opened nothing. The same watch now covers pending offers: a status change in an
offered chat asks the host again, once.

* fix(native-chat): a refused steer is read from the turn its handover named

The latest-request reader decided whether a refused send had joined a running turn by comparing
host clocks: its handover time against the previous turn's end. The handover row now states the
turn it delivered into, so the reader reads that instead and the clock comparison goes. A journal
written before handover rows stated a turn is scoped on replay from the turn open when each row
was written, which can differ from the clock reading only when a send and a turn's end share a
millisecond.

* fix(mobile): the native-chat controller contract carries the turn journal

The controller and overlay already pass nativeChatTurnJournal, but the
contract type never declared it, so mobile failed to typecheck.

* fix(native-chat): the live turn is the running turn, not the newest user row

A turn the provider opened on its own (a background wake, a resumed turn)
anchors on its own record, but the list still treated the newest user row
as the live turn. While such a turn ran, the settled user turn before it
lost its duration and the running turn's own rows were drawn as settled,
so its tool calls lost their live state.

nativeChatTurnMembership now answers both questions from the turn record:
each row's turn, and the live turn (the running root turn's anchor, else
the newest user row, which is also all an unscoped host has). Desktop and
mobile key liveness, the timing clock and the live status's row on it.

* test(native-chat): a turn the provider opened keeps its own clock

Pins that the local turn clock follows the live turn, so a wake after a
settled turn does not restart that turn's clock when no host durations
are recorded.

* fix(native-chat): a running turn no message opened draws its status on no row

Its live status belongs to the transcript-tail indicator alone. Once it
settles, its duration draws at its first row as before; a running turn a
message opened still draws on that message.

* fix(native-chat): every copy of a row carries the main agent's own status

History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.

- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
  matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
  malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
  as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
  rebuilding one; the sync key and history equality compare it.

* test(native-chat): pin the worktree ps verdict across host and phone versions

Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.

* test(mobile): name the parity table's row for its role

* test(native-chat): a roster of idle or finished children does not keep an agent awake

The sweep reads owed background work through the shared child-work liveness that upstream's
release clock adopted; a child that went idle or finished is not work the agent still owes.

* fix(native-chat): a request that settles while the user is asked something notifies once

The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.

The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.

* fix(orchestration): a task dispatched into a resting structured worker keeps it running

The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal
resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a
dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while
that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's
process incarnation now counts, derived from the existing rows.

* fix(native-chat): a command's wait ends when its child does

The delivery loop waited for a /compact only on the adapter's compaction
tracker, which learns of the child's end only on some exit paths: a Codex
exit or close, and a Claude close, never reach it. The wait then never
ended, so nothing queued behind the command was delivered again, Stop had
no child to answer through, and the tracker's leftover entry refused the
next /compact.

Every way a child ends passes endProviderChild, so the host now offers a
per-child end signal there. The loop races the tracker against it (the
dead-generation settlement has already written the command's verdict),
and on that end asks every adapter to release the command, so a later
command runs and no later provider turn is claimed into the dead one.
The adapters' own exit-time releases were unreachable (Codex) or covered
one path of several (Claude), and are removed.

The Codex RPC test harness moves to its own module so the exit can be
driven through the real adapter's connection callback.

* fix(native-chat): keep refusing sends during a command on an older host

An older host's controller still refuses a send while a conversation
command runs, so dropping the client's block turned every message typed
during /compact into a 'not sent' row with Retry there. The block stays
for hosts that do not run the command as a send-path turn, and goes only
for those that do.

The signal is one the client already holds: a host that runs /compact on
the send path states a turn scope on every journal row it writes, the
same fact turn membership uses to tell it from an older host. Both now
read it from one predicate. On an empty conversation, or one whose rows
all predate the upgrade, the signal is absent until the command's own
entry streams in, so that brief window keeps the old local refusal; no
capability or wire field is added.

* docs(native-chat): comments stop describing the hold this PR removed

Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that
pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive
subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it.
Comment-only.

* fix(native-chat): the completion says when the user is being asked

A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.

The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.

* fix(native-chat): a restart offer keeps the start its own continuation made

Whose start ended an offer was decided at read time, from whether the offer's continuation was
still the queued message. Once the provider refused that continuation, the child it had started
read as someone else's start, so the offer ended and its failure showed no Retry. The delivery
loop now records which queued message a start is for on the in-memory child, and the child's end
carries it; the offer counts a start as its own when that message is one of its continuations.

* fix(native-chat): a rewound turn still names the message that opened it

A Codex rewind rebuilds the epoch without submissions, so each sent message survives only under
its provider key. The kept turn records still named the submission key, so each turn anchored on
itself and its rows grouped apart from the message that opened it. The rewind now renames the
turn's opener along with the message.

* fix(native-chat): Stop ends only the command it names

Stop on a command turn abandoned whatever compaction the session had pending, so a late Stop for
an earlier /compact cancelled the one running now. The tracker now ends a command only when the
Stop names its turn, and the cancel reply reports whether it did.

* fix(native-chat): an agent gets a full idle window after its owed work ends

The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or
dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can
read done before the lead's wake-up turn writes anything, and stopping in that gap loses the
wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full
window afterwards, as the release clock it replaced did.

* test(claude): the options-read fixture runs a live child

The fixture marked its conversation running with a hasProviderChild field the
session type does not have, so the read took the at-rest path and refused a
session with no record. It now carries a child, which is what the read checks.

* test(native-chat): host tests reach its collaborators through a typed seam

The rest-test rig and three test files read the host's private members with
Reflect.get and cast the result. The host now exposes one test-only accessor,
collaboratorsForTests(), and the subscribers class a subscriberCountForTests()
beside its existing retainedActivityCountForTests(), so the tests are checked
against the real types and the casts are gone.

* fix(worktree-status): a departed agent's failure yields to live work on the worktree card

A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.

* refactor(orchestration): one owner answers a structured worker's custody

Routing, group addressing, worker-show and the idle sweep each composed their own reading of
whether orchestration still holds a structured worker, so each new obligation or retirement state
had to be added to every reader. structured-worker-custody now derives both answers from the
worker-terminal list state coordinators see in worker-list: addressable is owned and not released,
and owed work is an active custody or an unsettled task dispatched to the same incarnation. The
owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup
already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests.

* refactor(orchestration): owed work is an open dispatch on the worker's incarnation

A supervised worker's own dispatch context stays open exactly while the worker is active, so the
separate active-custody branch only repeated it. Owed work is now one fact, which also states the
policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are
written once at the top of the module.

* docs(agent-status): a departed agent's failure ranks below live work on the worktree card

* fix(native-chat): a restart offer knows its continuations by a tag in their id

The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running
action's id in memory. Both could disagree with the journal: past the cap an old rejected
continuation read as the chat moving on, and a crash during a retry restored the failure's older
entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer
(its teardown and chat), then the action's own part, so any continuation of this offer, queued or
rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and
the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own
continuation the start was for, read against the stored marker.

* test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait

The tui-idle probe reads through readTerminal, which now awaits the structured
worker check before the PTY read, so the probe's snapshot request starts a
microtask later. vi.waitFor missed it on its first check and polled again at
50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot
then resolved after the wait had already timed out, so the test passed without
judging it, and the rejection landed before any handler was attached. Vitest
reported that as an unhandled error and failed the shard.

Polling every 1 ms sees the request within a few ms, so the snapshot is judged
while the wait is still pending.

* fix(native-chat): a message held behind /compact is drawn where it was handed over

A message typed while /compact runs was drawn above the compaction's result, between
itself and its own answer. The reducer kept every item at the sequence and timestamp of
the row that created it, and a queued message is created at acceptance, long before the
command it waits behind writes its result. The phone orders by that sequence and the
desktop by that timestamp, so both put the message first.

A queued message now takes its position from its handover row, the same row that already
states its turn scope. Everything the agent did before the handover, a command it waited
behind included, draws above it. This holds for every held message, not only /compact's,
and needs no client change: every client, older builds included, reads the position the
host publishes. A live batch already carries the item when its dispatch row lands, and
history pages cut the reduced timeline by sequence, so paging stays contiguous.

* fix(native-chat): a phone's send during /compact answers without waiting out the compaction

A client that predates accepted-send replies, which is every phone build, has its send
reply held until the host hands the message over. A message sent during /compact is not
handed over until the compaction ends, so the phone's 15 s request timeout fired first
and showed the message as unconfirmed.

That wait now also ends once the message is queued behind a running command. This is
read from the journal's running turn and needs no new state. Every other wait still
ends at the handover: behind a starting child or an ordinary turn, and for restart
resume, the command front door and orchestration, which keep the plain handover point.

* perf(native-chat): a rewind places provider items with one pass over the merged rows

A Codex rewind gives each provider item the old epoch never held the turn record for its
provider turn. It found that record by scanning every merged row, restoring each row's
body, once per provider item. That is quadratic, and it runs on the host's main thread
up to the journal's 10,000-row cap, twice per rewind. A rewind record written before
rows carried their scope holds no scope for any provider item, so it paid the full cost.

The merge now indexes turn records by provider turn id once, keeping the first match as
the scan did, and each provider item looks its record up.

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* fix(native-chat): a message waiting behind /compact is drawn after it until it is sent

A message sent while /compact runs is placed where it was handed over. It was still
drawn where it was accepted until then. /compact writes its result one step before the
handover, so for that step the waiting message sat above the compaction's separator.

A message the host accepted but has not handed over is not part of the conversation
yet, so both clients now draw it after everything the agent has done. The shared
projection moves it to the end, which is the order the phone draws. The desktop ranks
it with the other not-yet-sent rows, after the streaming preview. At handover it takes
its place from its handover row, which is also after the separator, so it never
appears above the compaction it waited for.

* fix(native-chat): the idle sweep reads owed work every tick

Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it
refreshed the clock at most once a window. Work that ended just before the next read left the
agent to be stopped at that read, moments after the work ended, which is the gap the refresh was
meant to cover. The sweep now reads owed work on every tick for a started agent, so the window
always runs from the last tick that saw work owed.

* fix(native-chat): a continuation handed to the agent stays sent

The offer read its own continuation as not reaching the agent while its dispatch was pending, which
also covered one already handed over and still unanswered. When the wait for that answer ended first,
the failure it filed read as retryable, and a retry sent a second continuation to an agent that may
have acted on the first. Only a continuation still queued, or rejected, is now read as unsent.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(cross-version): load the phone row readers without mobile's toolchain

Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.

The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.

* test(cross-version): keep the checkout path-guard message and justify the copy import's cast

* fix(native-chat): a command ends only by its own provider answer or its child's end

Stop no longer settles a conversation command. It interrupts it like any turn,
and when the provider cannot take that (Codex has not opened the command's turn
yet, or Claude refuses the interrupt) it stops the child, whose dead-generation
settlement writes the verdict.

The pending command now lives on the provider child's own session instead of an
adapter-wide map keyed by session, so it dies with the child and nothing has to
release it. Claude's /compact is sent under a uuid the slot records, and only a
root result naming that input (or naming none) ends it; its outcome is read with
the ordinary result reading, so a stopped /compact is a cancellation.

* fix(native-chat): a command's settle answers its message before ending its turn

The two writes are not one batch. Writing the message's answer first means a
crash between them leaves a running command turn, which the stale-turn sweep
already settles, instead of an ended turn whose message reads as in flight
forever. The settle now writes only while the command turn is still running.

* fix(native-chat): "Worked for" counts from the handover, not the send

A message held behind /compact, or behind a cold start, used to count the wait
as the agent's work, although its row is drawn at the handover. Every handed-over
submission's turn, the command's own included, now starts at the handover row's
instant, falling back to the send time for a host that recorded none.

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): the interrupted create's own retry continues again

The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its
own operation id, with a fresh start whose result nothing read. That fresh start passes with the
released-reservation continuation deleted, so the case the fix exists for went untested. The retry
and its assertion are main's again.

* docs(native-chat): three comments that still had views starting agents

A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction
left alone would refuse every send, so no agent would ever start to finish it; and a current host
raises the unattached read refusal only once quit began, with the attach window belonging to an older
host.

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(native-chat): a second Stop on a command ends its child; one compaction verdict for every provider

A Stop's note now names itself in its key, so a later Stop on a command still
running reads, from the journal, that the provider was already asked and never
answered, and stops the child instead of interrupting again. Nothing is held in
memory for it.

Adds the rule both translators will read a compaction's end by: only a
compaction the provider reported is a success; none after Orca's interrupt is a
cancellation; anything else is a failure. A real Claude capture, pinned as a
fixture, is why: a stopped /compact ends in the same success result as a
finished one.

* test(native-chat): a reader's open settles the turn a failed exit settlement left running

An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now
settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle
sweep closed, and a read that opens the chat before the restart restore reaches it.

* test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner

A subscription reads the conversation before it returns, so under load the two views took longer
than the create child's 300 ms start, which then exited before the test checked that it had not.
The child now takes a second to fail.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* refactor(native-chat): the provider's translator ends a command's turn; the loop holds no command state

A conversation command is now a turn of the provider child's own journal
pipeline. The adapter-wide tracker, its promise and the loop's settle step are
gone.

- Codex: the translator claims the provider turn that carries the command, scopes
  its rows to the command's turn, and writes the command's end in the same batch
  that settles that turn. Codex's own compaction marker is the success row.
- Claude: the command's turn is the translator's open turn until the result that
  answers the /compact input ends it. The command's own frames, such as the
  continuation summary, its echo and "Compaction canceled.", draw nothing.
- Both read the end with the one compaction rule: success needs the provider's
  report of the compaction; none after Orca's interrupt is a cancellation.
- The message resolves at the provider's receipt, as any send does: the Codex
  ack, or the Claude slash-command waiter on its result. The host writes a
  command's end only when the provider never took it.
- The delivery loop stops while a command's turn runs, and every journal commit
  re-wakes it through the session's serialize, so an end that lands while a step
  decides to stop is never lost. A child that ends first is settled with it.

* test(native-chat): pin a command's end to real /compact frames and to each path it threads

The captured /compact frames drive the Claude translator's command turn: a
finished compaction ends as a success with only the separator drawn; a stopped
one ends as a cancellation with no failure row, and the next send answers in its
own turn; a result naming another input ends nothing. The command's end is
checked at each point the ordinary result path threads through: the reopen latch
after a failure, the settling of a child still working, the context facts the
result reports, and the provider's own error row.

On the host: a message held behind a command is handed over when the command
ends just as the loop stops for it, a refused command settles as a failure and
the loop moves on, and a Claude child that exits mid-command settles the command
and hands what waited to a fresh child.

* test(native-chat): tests merged from the base state which turn their rows belong to

* refactor(native-chat): drop the child-end waiter nothing waits on

A command no longer waits for its child here: its turn ends from the provider's frames or from
that child's settlement, and the delivery loop is woken by the commit. The waiter and its test
were left from the earlier shape.

* fix(native-chat): a command holds the queue only while its child runs it

The delivery loop stopped whenever the journal showed a command's turn running. When the
command's child ended and its settlement could not be written, that turn stayed running with
no child to end it, and the loop's gate kept it from ever starting the next child, which is
what settles a gone generation's leftovers. Every later send was held for good, and Stop had
no child to end.

The gate now holds only while the conversation has a child: with none, the command belongs to
a gone generation, and the loop's start settles it like any turn a dead child left running.

* fix(native-chat): a Claude /compact succeeds only on its compaction boundary

The command's evidence counted Claude's `compact_result: 'success'` status as the compaction
done. That status comes before the boundary that replaces the history, so a Stop landing
between the two read as a finished compaction even though no boundary was ever written. Only
the boundary now counts, as the rule for both providers states; the capture's finished
compaction carries one, so it still reads as a success.

* fix(native-chat): a Claude child's exit says why the turn it ended stopped

When a Claude child exited mid-/compact, the command showed "Worked for 0s" and no reason. The
child's translator ends its open turn the moment the exit is reported, stamped with the exit's
instant, so by the time the exit settlement ran nothing was running. The settlement recognises a
turn the exit already ended by that same instant, but the Claude lifecycle event dropped it on the
way to the host, which then used its own clock, matched nothing, and wrote no row. When the clocks
did agree, the row was scoped to the running turn, of which there was none, so it landed outside
the turn it explained.

The exit's instant now reaches the host, and the exit row belongs to the turn the exit ended:
still running, or ended by the translator at that instant.

* fix(native-chat): a message waiting behind /compact draws below its live activity

A message sent while /compact runs waits on the host until the command ends. Both clients moved
it to the end of the transcript rows, but the running turn's live activity line ("Compacting the
conversation") draws after every row, so the waiting message sat between the command and its own
live status.

A row that is queued, and not what the live turn is for, now draws after that live activity: on
desktop outside the transcript window, below the activity line; on the phone in the list footer,
below the live status. A message whose own start is pending still draws above the activity that
start reports.

* fix(native-chat): only a running command holds a message below its live activity

A message is accepted, then handed over a moment later, and in between it reads as waiting. Every
message waiting behind a live turn drew below that turn's activity line, so an ordinary message
sent while the agent was working crossed below "Thinking" and jumped back up once it was handed
over, on desktop and phone. Only a conversation command's turn holds the queue on the host.

A message now waits below the live activity only while the running turn is one a command opened,
read from the entry that opened it. The phone test also typechecks, which the mobile test ratchet
requires.

* test(codex): the claim test names its notification params as a record

* test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn

On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the
dead process's lease still reads live. The open settles the turn it left running anyway, and the
restore that follows finds it settled.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* docs(native-chat): drop the removed dispatch hold from six comments

A worker's session no longer takes a dispatch hold, and no release clock
rests a chat by visibility; the agent-launch comments, the abandon test,
the teardown test and the refusal census still said so.

* test(native-chat): rest the owner-status chat through the idle sweep, not a hold

The activation-gate test from #22808 put its chat at rest by holding and
releasing it, and passed the release-clock grace. This branch deleted both,
so the case threw before it reached its assertions. It now moves the host's
clock past the idle window and lets the sweep stop the agent and close the
conversation, then asserts the same owner answer and activation gate.

* fix(native-chat): show the structured pane's retrying line when a read fails

The read transport always hands the pane the host's words, so the error
state's "Orca keeps trying to load it" line, which showed only when there
were none, was never seen: the pane showed the host's text twice, as its
subtitle and on the status line under it. The structured pane now always
says its read keeps retrying, and the host's text stays on the status line.
The terminal-backed chat is unchanged.

* fix(native-chat): a send the provider never received after a restart has no verdict

Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.

* fix(native-chat): a failed Codex compaction's late completion writes no turn of its own

Codex ends a failed turn with an error and then still completes it as failed.
The error settled the compaction and released its claim on the provider turn,
so the completion read that turn as an ordinary one and wrote a stray record.
The claim now lasts until the completion, which adds nothing to a command the
error already ended.

* test(native-chat): the mid-command exit case resumes its next child as a real one does

The case's fake started every child as a newly created thread with the same generation. The
store refuses a created link once the conversation has a thread, so the next child's start
failed and wrote its own error row, which landed before or after the case read the journal.
The next child now resumes the thread under its own generation, and the case reads the
journal once the waiting message is delivered, which also proves the loop moved on.

* test(native-chat): wait for a send's background start before the refusal oracle removes its store

An accepted send wakes the delivery loop, which starts the agent in the background. The oracle's teardown disposed the loop but did not wait for that start, so its lease write could create a temp file in the store directory while the directory was being removed, failing the test with ENOTEMPTY about one run in four. The teardown now drains tracked starts before it closes the journals.

* fix(native-chat): a start a message waited on gets one failure row, the delivery loop's

When a queued message's start failed, two writers could report it under the same row: the delivery loop, when the adapter settled the start without proving it, and the exit settlement, when the child's exit landed. The last one won, so the chat's row could name a different cause than the one the message was rejected with, or be written twice.

The exit settlement now writes the start's row only when no message is queued and the loop has not already recorded that start. A start for a command, goal change or rewind, with nothing queued, still gets its row from the exit.

* fix(native-chat): a /compact whose start failed says to run /compact again

The failure-words context named only /clear as a command to retry, so a
/compact whose agent failed to start read "Send your message to try again."
on its row, its rejected message and the command reply. The context now
carries any conversation command; the host derives it from the oldest
message still waiting on the provider, which is the one a failed start
fails first, and the /compact reply names it directly.

* fix(native-chat): a Codex /compact ends only on its turn's completion, below Codex's own error row

Since only turn/completed ends a Codex turn, Codex's turn-ending `error` is a row
inside the still-open command turn, and the failed completion that follows it is
the command's end: completed, outcome failure, at the completion's receipt time.
The command's own "Compaction failed" row was written on that completion too, so a
failed /compact read its reason twice.

The command turn now notes when Codex's turn-ending error for the turn it carries
was written as a row, and its end then adds no second row. A retried stream error
ends nothing and is not counted. The flag that let the error end the command and
kept the claim until the completion is gone with the error-driven end.

A test replays the captured failed compaction from the real app-server through a
claimed command turn.

* test(native-chat): main's crash-turn test states its row's turn, and a dead /compact settles on its recorded exit

Two tests the main merge brought together:
- The crash-turn test from #23456 writes a turn record through the event sink
  without options; every row here states its turn scope, and a turn record's is
  the thread.
- The /compact whose exit settlement could not be written no longer stays running
  until the next start: main now settles an open chat from the exit it recorded, so
  the command reads interrupted before the next message, which is then delivered.

* test(native-chat): main's new journal tests state each row's turn

The crash-turn, stale-turn and sink-queue tests main added wrote rows without a
turn scope, which every item write now states. Rows written inside a running
turn name that turn; the sink-queue batch and a send handed over with no live
turn name the thread.

* fix(native-chat): draw a queued turn's message after the earlier turn's rows

A message sent while A runs is written to the journal when it is sent.
When the provider queues it (Claude answers it after A), A's remaining
rows - its last tool run and its answer - are written after that
message, and the message's own turn opens only after them. Grouping put
those rows in A's turn, but the transcript still drew them in journal
order, below B's bubble and bar, where A's answer read as B's reply. This
is the residual #23671 left open.

A message that opened a turn now draws after the earlier turns' rows the
journal wrote after it, just before its own turn's rows
(nativeChatTurnDrawOrder, returned by nativeChatTurnMembership as
drawOrder). Desktop and mobile both draw in that order. A steer, and a
message that has opened no turn yet, stay where they were written. It
applies on hosts that state turn scopes and, through journal order, on
older ones.

* test(native-chat): run #23026's Stop tests against #23059's command turns

Two of #23026's tests call APIs #23059 changed, and failed after the
merge:

- codex-structured-conversation-stop: a compaction now goes through
  adapter.compact with the command run the host wrote (#23059), not a
  bare turn id, and answers with the provider's receipt. With the command
  claimed, a Stop that names no turn while the compaction's provider turn
  has not opened still interrupts nothing.
- main-agent-working-agreement: a provider row states its turn scope
  (#23059's appendItem contract); the retry and subagent rows are
  conversation-scoped.

* fix(native-chat): typecheck main's Stop and restore-grouping code against #23059

A Stop's compaction interrupt reads the narrowed requested turn, and the
restore-grouping test states whether each row reports its turn's outcome.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-29 13:17:03 -07:00
Brennan BensonandClaude c49388cd33 fix(native-chat): Stop is there from the moment a message is sent (#23026)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* fix(native-chat): a Stop that names no turn stops what the conversation has in flight

Between handing a message to the agent and the agent opening its turn, there is no turn id a
client could name, so a Stop in that gap was refused as "already finished" while the agent went
on to answer. A cancel's turn id is now an optional precondition instead of its target: with
none, the host withdraws what is queued and, when the journal still reads working, asks the
adapter to stop whatever the child has in flight. Claude's interrupt is session-scoped, so it
is guarded by fence and acquisition generation rather than a turn identity. Codex interrupts
the turn its latest turn/start answered with until the journal shows one.

A cancel that names its turn behaves exactly as before.

* fix(native-chat): Stop is there from the moment a message is sent

The composer showed Stop only once the agent had opened a turn, so for the second or two after a
send the chat read "thinking" with no way to stop it. Against a host that takes a Stop naming no
turn, Stop now shows whenever the chat reads working (a turn, a queued message, or a handed-over
one still unanswered) or this client still has a message on its way. Pressing it, or Escape,
first drops every outbox entry the journal does not hold yet, so nothing goes out after the
Stop, then sends the conversation-wide cancel. A send already on its way reaches the host ahead
of the cancel, which withdraws it there. Against an older host Stop still needs a running turn.

The unconfirmed-send probe moves into its own hook so the outbox hook stays in budget.

* fix(native-chat): Stop before a turn is gated on its own host capability

A host that accepts sends first (agent-session.accepted-send.v1) can still predate the cancel
that names no turn and would refuse it as invalid, since clients and hosts ship independently.
Hosts that take that cancel now advertise agent-session.conversation-stop.v1, and the renderer
shows Stop before a turn opens, and sends the no-turn cancel, only to a host advertising it.
Every other host keeps a Stop that needs, and names, a running turn.

The host capability probe the accepted-send hook used is generalized so both read one path.

* test(native-chat): a build advertises conversation stop exactly where its cancel may name no turn

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* fix(native-chat): Stop reads the one working rule every session list reads

While Claude retries a rate-limited request it never echoes the message, so no
turn opens: the sidebar read Working from the unanswered send while the composer
showed Send. The chat's working state, the host's session-list status and the
host's no-turn Stop check now call one shared rule instead of three copies.

* test(native-chat): a rate-limit retry pins only that no turn opens, not how its rows are kept

* fix(native-chat): Stop leaves a message waiting on its Retry, and does not show for one

A send that failed holds the queue until the user retries it, and one the host restarted under is
parked the same way. Stop counted both as still on their way, so it showed in an idle chat and
could never go away, and pressing it dropped the failed message along with its Retry.

* test(native-chat): the chat's Stop and a session list read the main agent alike over their own copies

The chat reduces its stream and a list reads the status feed. Driven through the real host for a
rate-limit retry with no turn, a subagent still running after the main turn, and the handed-over
child exiting.

* refactor(mobile): the chat reads the main agent's working state through the shared rule

Behaviour is unchanged: the same two terms, now from the one function the host projection and the
desktop chat read.

* fix(codex): a Stop naming no turn never interrupts an earlier turn

It fell back to the id an earlier turn/start answered with when the latest start went unanswered,
or when the journal showed a compaction Codex had not started, and reported that as stopped.

* fix(native-chat): a Stop naming no turn never says a turn had already finished

When the provider found nothing left to stop, for instance a turn that ended between the host's
check and the interrupt, the chat got "The provider had already finished this turn." for a turn
the Stop never named. It now ends quietly, as a Stop with nothing in flight does.

* fix(native-chat): one Stop the host could not settle no longer refuses every later one

A Stop naming no turn has one operation key per session. When the host could not settle one, it
answered every later Stop under the same id as unknown until the id expired. Once the host says
so, the next press is a new Stop; transport doubt still replays the same id.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* test(native-chat): read Stop operation ids without a cast

* fix(native-chat): a Stop whose answer was lost no longer swallows the next one

A Stop that names no turn has one operation key per chat. When its answer was lost in transit, the
chat kept the id, so every later Stop replayed it; the host answers a replay as already handled, so
for up to a day Stop stopped nothing. The id is now dropped once the call settles, however it
settles. A second press while the first is still on its way still shares its id.

* refactor(native-chat): a Stop naming no target keeps its operation id only for its own call

The chat kept each write's operation id per payload across calls, and dropped it only on some
settle paths. That is right for a write naming what it acts on, but a Stop naming no turn, and a
stop of every background task, share one payload with every later one, so any path that kept the id
made the next Stop replay as already handled and stop nothing. One path was still open: an answer
that arrived after the chat moved to a new fence.

Whether a write names its target is now decided once, before its id is picked. One that names none
keeps its id only while its call is in flight, so a press made meanwhile joins it, and releases it
when the call settles, however it settles. The release runs only while the key still holds that
call's id, so a joined call settling late cannot drop a newer one's. This replaces the per-path
exceptions for a thrown call.

* test(native-chat): read the Stop fences without a cast

* test(native-chat): pin the new id for a named cancel the host could not settle

After the Stop naming no turn moved to a per-call id, the only test of the unknown-refusal release
was gone, and the half that stays, for a cancel naming its turn, could be removed with every test
green.

* fix(native-chat): a Stop pressed after a new message stops it, even while the last Stop is unanswered

A Stop naming no turn shared its operation id with any press made while it was still in flight. The
host runs a chat's writes in order, so a message sent between two presses was accepted after the
first Stop ran, and the second press replayed that Stop as already handled and left the message
running, although the chat had already withdrawn it from the outbox.

A write naming no target now gets a new id on every press and is never kept, so each Stop acts on
whatever is running when the host reaches it. A write naming its target keeps its id exactly as
before. A double press can ask the provider to stop the same turn twice, which it tolerates.

* fix(native-chat): Stop no longer blinks off as Claude opens the turn for a message

Claude's echo of a sent message both answers the send and opens its turn. The echo settled the send
first, so the host published the message as answered one frame before the turn it opened, and for
that frame the chat read nothing running: Stop turned back into Send, and Working blinked off in
every session list, for tens of milliseconds on each turn.

The echo now settles the send after the turn it opens has been emitted, so the running turn is
published first.

* fix(native-chat): a message a Stop withdrew comes back to its sender's composer

A Stop withdraws every message the host holds but has not run, and S also
drops the ones this client had not handed over yet. Either way the message
left the chat and its text survived only in a hidden journal row and the
in-memory ArrowUp history.

The sending client now puts the withdrawn text and images back in that
pane's composer, after whatever is typed there. Withdrawn is read from the
rejection reason through one shared check, which the outbox reconcile now
uses too. The composer is written before the entry leaves storage, so a
failure between the two repeats the text instead of losing it, and an entry
storage no longer holds is never given back again, so a replay, a second
view or a remount restores it once. Only this client's outbox holds the
entry, so other viewers still see the message disappear. A failed Stop
withdraws nothing on the host and gives nothing back.

* fix(native-chat): withdrawn text put back during an IME composition is not lost

While the IME owns the field, the composer ignores a programmatic draft, and
the next composed keystroke wrote the draft without the restored text, after
its outbox entry had already been dropped. The composer now holds text
appended mid-composition, keeps it in the cache after each composed write,
and shows it once the composition settles, the way attachments that land
mid-composition already wait for it.

* test(native-chat): pin that only a withdrawn message comes back to the composer

* test(native-chat): set up the composer's window API for every describe in the composition-race file

* docs(native-chat): note that the withdrawn check reads the legacy reason until a typed category lands

* test(native-chat): pin that text put back mid-composition shows once, even beside a mid-composition clear

* fix(native-chat): land a late settlement from a streamed turn's end after that turn's rows

A settlement that says a streamed turn ended waits for the session's event
sink to drain before writing its dispatch row. The journal reducer still
refuses to overwrite an accepted or rejected send.

* fix(codex): settle a send from the end of the turn Codex answered it into

The turn/start answer names the turn that holds a send. The send's echo
entry now keeps that binding, in memory only. If the bound turn is
interrupted without echoing the send, the send is withdrawn: Codex clears a
turn's pending input on interrupt, so the model never saw it. If the turn
fails first, the send is rejected in Codex's words. A completed turn settles
nothing, since Codex records pending input when it finishes and the echo is
still due. An answer read after its turn already ended is settled by that
end. The echo is still the acceptance and carries the item key.

* test(codex): a send settles from the end of the turn Codex answered it into

The fake Codex keeps 0.157's turn bookkeeping, and can deliver the turn/start
answer after turn/started or after turn/completed. The tests cover:
- a Stop before any echo withdraws the send, and the working rule reads idle;
- a steered follow-up is withdrawn when the turn is interrupted;
- a failed turn rejects the send in Codex's words;
- a completed turn leaves the send to its echo;
- a normal echo and a late echo;
- two steered sends in one turn;
- an answer read after the turn ended;
- a timed-out answer;
- child-thread turns;
- how a binding dies.

* refactor(native-chat): drop the stream flush before a late turn-end settlement

Nothing reads the order of a dispatch row against the turn's terminal row:
the reducer keeps a settled send terminal and the working state is derived
from both. The echo acceptance on the same path never waited either, and the
wait could drop the settlement on a failed sink barrier.

* fix(codex): settle a failed turn's sends at its end, not at its error

Codex keeps a failed turn's pending input and records it after the error
frame, before turn/completed. Settling at the error rejected a steered
follow-up the model had in fact received, so a Retry would send it twice.

* docs(codex): say a completed turn echoes what it took before it ends

Codex records a completed turn's pending input before `turn/completed`, so
a bound send that turn never echoed is left for recovery, not awaiting an
echo. The comments and one test title said the echo was still due.

* test(codex): settle a send whose answer is read after its turn failed or completed

A failed turn that ended before the answer rejects the send in Codex's words,
once; a completed one leaves it admitted and still armed for its echo.

* refactor(codex): read a failed turn's reason with the typed thread-fact reader

* fix(native-chat): a Stop that names no turn and ends nothing says why

The host sends a Stop naming no turn to the agent only while the chat reads
working. When the agent ended nothing, the Stop wrote no row, so it looked
ignored. It now writes one: Stop could not reach the agent, in the agent's
own words when it refused the interrupt.

* fix(codex): hold a cold send until Codex opens its turn, and stop the turn it opened

Codex answers turn/start before it opens the turn, and refuses an interrupt
until then. A Stop queued behind a cold send ran in that gap, named the
answered turn, and was refused. The send's handover now lasts until Codex
opens that turn, or provably will not: the turn ended, the primary thread
stopped running, or the child ended, bounded by the turn/start deadline.
A steered send, whose turn is already open, does not wait.

A Stop naming no turn now interrupts the turn the journal shows, else the
primary-thread turn Codex reported started and not yet ended. The per-start
answered id is gone: it was never cleared at a turn's end.

* fix(native-chat): give back a send already on its way only when the host withdraws it

Stop took the in-flight send out of the outbox and put its text back in the
composer at once. The send still landed ahead of the Stop, so the chat read
working for a moment before the host withdrew it, and a send the agent had
already taken came back as well. The in-flight send now stays until the host
answers it, and the withdrawn-message restore gives it back from that answer.

* refactor(native-chat): read a Stop's withdrawal through the rejection classifier

dispatchWasWithdrawn matched the legacy reason string. It now asks the
classifier, which reads the typed fact first and keeps that string only as its
own fallback, so a withdrawal written as a fact with a sentence is still given
back to the composer.

* fix(native-chat): a Stop Codex took but Orca could not confirm is not reported as reaching nothing

When Codex acknowledged the interrupt but Orca could not verify the turn's
processes ended, a Stop naming no turn wrote "it had no turn running to stop",
though the turn then ended as interrupted. The adapter now says the Stop was
taken but unconfirmed, and the row says Cancellation was not confirmed.

* fix(codex): a held cold send never delays closing the chat or quitting

A cold send's handover waits for Codex to open its turn, and that wait sat
in the session queue ahead of the close and quit eviction, so either could
wait out the 30 s request deadline. The host now releases those waits before
it queues a close or starts quit teardown, and the adapter releases them when
it is asked to close the child and on every exit, including one whose end
publication is backpressured.

* fix(native-chat): a refused Stop says the agent declined, and names it

"Stop could not reach the agent" was wrong: the agent was reached and
declined. The row now reads "Codex had no turn running to stop." or
"Codex didn't stop: <Codex's words>.", naming the chat's agent.

* fix(codex): a Stop waits for the turn Codex answered to open; the send no longer does

Codex answers turn/start before it opens the turn and refuses turn/interrupt
until then, so a Stop naming no turn in that window was lost. The send's
handover used to wait for the turn to open, and a close or quit needed its own
release to get past that wait.

Now only the Stop waits. A Stop naming no turn, finding no journal turn and no
open one, reads the turn Codex answered the latest pending send into and has
neither opened nor ended, and waits for it: bounded at 5 s, under the quit
eviction budget, and ended by that turn opening or ending, the thread going
idle or failing, or the child exiting. If the turn opened it is stopped;
otherwise the Stop reports that Codex had no turn running. The send returns at
Codex's answer, so a close or quit with no Stop pending is never delayed, and
the release plumbing through the adapter, router, close and quit is gone.

* fix(codex): the Stop's wait reads a stopped thread from the thread-facts reader main kept

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-29 01:39:49 -07:00
Brennan Benson 2dc2693953 fix(native-chat): a turn a proven crash cut short reads interrupted (#23456)
* fix(native-chat): a turn a proven crash cut short reads interrupted, ending when it was last seen working

* fix(native-chat): end a probe-proven turn at the last row the journal wrote live

A revised item keeps its first sighting's timestamp, so a long command or a streamed reply read as ending when it started. The reducer now tracks the latest live row the same way it tracks all activity.

* test(native-chat): give the unexpected-exit fake journal its live-activity read

* refactor(native-chat): read the journal's live bound only for a probe-proven death

* fix(native-chat): mark what a journal open settles for a gone host as crash reconciliation

A crashed host's working roster is retired when the journal reopens. That row was
written live, so a probe-proven turn ended at the relaunch and counted the downtime.

* fix(native-chat): bound a probe-proven death with the last time Orca proved the owner alive

A crash mid-tool left the turn ending at the tool call's start, because Claude writes nothing
while a Bash call runs. The death evidence now records the lease's last renewal before the
death as lastProvenAliveAt, and the turn ends at the later of that and the last live row,
capped at the probe.

Parking a lease in recovery no longer stamps lastRenewedAt, since nothing proved the owner
alive then; a child that outlived Orca would otherwise have its turn count the downtime.

* test(native-chat): a failed acquisition parked in recovery keeps its last proof of life, and the timing read goes through the display selector

* refactor(native-chat): move the submission dispatch folds out of the journal reducer

Main grew the reducer to its line limit, so the live-activity bound tipped it over. The dispatch
row and echo-acceptance folds move unchanged into their own module.

* test(native-chat): a send or reader that opens a crashed chat settles a proven death interrupted

Main's open-time settle test still asserted the old rule, where only a watched exit proved a death.

* docs(native-chat): say which proofs of death record a last proof of life

* fix(native-chat): a proof of life bounds only the owner that wrote the turn

A start after a crash that spawned a child and then failed without proving it gone parks that
child for recovery; when recovery finds it gone, the proof of death carries its proof of life,
which is after the crash. The older turn then ended there and counted the downtime.

The journal now derives the fence of its newest live writer, and the lease that holds the proof
names the owner it released by the fence it moved to. The last renewal counts only when that
move was one step past the writer of the turn.

* test(native-chat): a crash with a send in doubt still ends at the last proof of life

The reopen settles that send at the new fence, so the owner check must read the fence of live rows only.

* fix(native-chat): a proof of death judges only the turn its own owner wrote

The settle read the record's latest proof of death for whatever turn a gone generation left
running. After a crash, a start that reserved a new fence cleared the relaunch's proof, and if
it then failed, its own child's death (a watched exit at the failure, or a probe finding the
child it left for recovery gone) ended the older turn an hour after the crash.

Every proof of death now records ownerFence, the fence of the owner or reservation it is about;
a fence names exactly one owner. The journal derives the fence each item was created at, and a
running turn is interrupted only by a proof naming its own owner; otherwise it is unverifiable.
Evidence older builds wrote keeps their rule. This replaces the derived one-step fence check.

* test(native-chat): every writer of a watched exit names the owner it released

A watched exit that names no owner reads the older rule, so the settle alone cannot tell a
dropped field; the writers are pinned directly, including past a recovery floor.

* test(native-chat): give the fake journal's cast its safety rationale

* fix(native-chat): a proof of death written after a chat opened revises the turn it left unverifiable

On desktop the chat on screen at relaunch opens before the startup reconcile has probed its owner,
so the open settles the cut-off turn unverifiable. When the reconcile then records the proof, it
re-runs the same settle for every open conversation, which revises that owner's unverifiable turns
to interrupted with the proof's end. Any later open re-runs it too, so a failed write converges.
Only upward, only for a proof that names the turn's own owner.

* test(native-chat): a chat read before the reconcile reads unverifiable, then interrupted

Covers the reconcile revising an open chat to the last renewal (27 s) and a subscriber being sent
both states, a start after the crash whose running turn the queued revision leaves alone, a failed
revision write converging at the next open, a proof about another owner or from an older build
never revising, and a second settle writing nothing.

* fix(native-chat): revise an open chat's turn wherever a proof of death is written

The store tells its listeners, once committed, of each record a transaction gave a new proof of
death, so every writer (the startup reconcile, a recovery that stopped a child which outlived
Orca, a failed start, a watched exit) triggers the same serialized resettle for a chat already
open. The reconcile's own callback is gone. Quit stops listening first, and a queued resettle is
drained with the starts.

* test(native-chat): a failed exit settlement is retried in place once the exit is recorded

Recording a watched exit now queues the same settle an open runs, so the turn converges without
waiting for the chat to be reopened. The reopen and read-after-restart cases now refuse that retry
too, so they still pin the open's own settle.

* test(native-chat): tests that pin a send settling a failed exit refuse the in-place retry too

The exit's release now queues the same settle, so two tests named for the send's settle refuse that
retry as well; the comments that said nothing retries it now say what does.

* fix(native-chat): name the explanation row by the death it explains, so a retried settle adds no second row

* fix(native-chat): end a crashed turn at its owner's provider output, never at a later send

A send accepted into a crashed chat before the proof of death wrote a submission row live, and the journal-wide last-live-row bound counted it, so the revised turn ended at the send and counted Orca's downtime. The bound is now the last row the owner's provider child wrote, per writer fence: submissions, dispatch rows and crash reconciliation are Orca's or the user's, and a newer owner's work says nothing about the dead one.

* fix(native-chat): end a crashed turn at its last proof of life, never at a timeline row

The end of a probe-proven death was the later of the last renewal and the last live timeline row. A send accepted into a crashed chat before the proof writes a row live, so the revised turn ended at the send and counted Orca's downtime. Rows cannot tell the agent's output from Orca's or the user's, so the end is now the last renewal alone, never after the probe, and never before the turn began. The journal's live-activity bound and the reopen's recovered marker, which existed only for it, are gone.

* test(native-chat): drop a stale reference to the removed live-activity bound
2026-09-28 20:48:34 -07:00
Brennan Benson d5451d9ec0 test(cross-version): a released client's capabilities come from its own release (#23533) 2026-09-28 12:39:23 -07:00
Brennan BensonandClaude 7a24d3d335 fix(native-chat): the conversation outlives its agent (#22835)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* fix(native-chat): the conversation outlives its agent

Opening a chat no longer starts its agent. A conversation is reached through one host
accessor that opens its journal at rest, and a send is what starts the agent, through
the delivery loop. One idle sweep, every five minutes, stops an agent that has been
quiet for thirty minutes and owes no work, then drops an open journal handle that is
only a cache. Its record, tab, status row and readers stay.

- hold and release are no-ops; hold still builds the host for shipped mobile builds.
- The holders, the holds, the release clock and the exit respawn are deleted.
- Options, the model list, the goal and the context meter answer at rest; a model pick
  at rest is recorded as intent for the next start.
- Compact, rewind, clear and goal changes start the agent first. A send does too when
  a rewind is still in doubt after the conversation opens.
- Orchestration routes mail and group addresses on ownership (the record plus the chat
  tab), not on whether the process runs. An open dispatch keeps its worker running.
- The restart continuation is a send; Resume all holds each slot until the message is
  handed over or rejected.
- A read error never replaces a loaded transcript, and shows the host's own words.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a restart offer ends when the chat's agent starts again

The offer used to end only when the chat's newest user message changed,
because opening a chat started its agent and that start could not be told
apart from real activity. Opening a chat starts nothing now, so the host
reads the fact it already publishes: a chat's status row goes from not
host-owned to host-owned exactly when its agent is started. At that edge the
offer and any failure record for the chat are withdrawn, unless the start is
a resume action's own (its continuation is the oldest undelivered message).

A continuation and a message racing to be first are decided at acceptance:
the continuation is refused, quietly and with nothing filed, when any other
message was accepted since the restart. A failed continuation start leaves
the offer retryable, and each resume action sends its own message id.

Deleted: the newest-user-message comparison, its journal reader, the
continuation filter, and the failure ledger's own "answered by the chat"
check. The marker still carries its message id for one release, so the
previous build can read it.

* fix(runtime): end a transcript stream when its client unsubscribes

Desktop: the IPC subscription controller was dropped as soon as the streaming
handler returned, which for most streams is right after it binds. A later
runtime:unsubscribe then found nothing to abort, so the host kept the subscriber
and derived and sent every publish to a channel no one listened to. The controller
now lives until the renderer unsubscribes, resubscribes the same id, or goes away.

Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe
with the stream's frame id, so the host ends that subscriber and leaves a sibling
stream on the same socket running. The direct path now passes the frame id the relay
path already passed.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* fix(native-chat): one fact ends a restart offer: the chat moved on since the restart

The offer is live while no other message has been accepted in the chat since the
restart and its agent has not proved a start since. The offer list, the resume's
reservation check and the continuation's acceptance check all read that one fact,
so a message whose start then failed withdraws the offer too, and a stale click
finds nothing to act on.

The fact is read off the conversation's open handle, which the restart closed, so
it is retired durably whenever it may have changed: a message accepted, a start
proven. A close and reopen within the same run therefore cannot bring the offer
back. A continuation rejected before it reached the agent does not count, so a
retry after a failed start still runs.

Deleted: the quit-time gate on withdrawal, which changed nothing because the
withdrawal and the quit's own offer write share one queue; the per-action
"withdrawn" flag and the separate acceptance check it paired with.

* test(native-chat): an older build reads the restart offer this build records

The offer lives in a file the previous release reads after a downgrade. Pin that
against the pinned release's own capsule, and run the lane when the marker or the
capsule changes.

* fix(native-chat): read a restart offer against where the journal stood when it was taken

"Since the restart" was read off the conversation's open handle, which the idle
sweep closes: after a reopen, a message the user had already sent looked older
than the handle and the withdrawn offer came back.

The offer now records the journal position (epoch and sequence) at the moment
it is taken, and a message accepted after that position, or a journal on another
epoch, means the chat moved on. That is derived from the journal, so it holds
across any number of closes and reopens. An older build's offer has no position;
only a start withdraws it. Because the message half is now durable, the offer is
no longer rewritten in the recovery file on every accepted message; a proven
start still writes it, since only the host that saw the start knows of it.

* test(native-chat): wait for the listing's retire write before reading the recovery file

* fix(native-chat): keep the terminal-backed chat's read error over its local echoes

Messages winning over a read error is right for the structured chat, whose read retries and whose
messages came from the transcript. The terminal-backed view assembles its list from local echoes
too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no
error. Only the structured pane now keeps messages over an error.

* fix(native-chat): a start retries the exit settlement a failed journal write left owed

An agent exit whose journal settlement write failed releases the lease latched until a retry lands.
Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried
it before the next app launch, and every send was refused. The start the send needs now runs the
retry first, where the attach would.

* perf(native-chat): answer the owner check without opening the chat

Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the
answer comes from the session record alone. Reaching it through the accessor opened each resting
chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it
open for the idle window. It now checks the record and the adapter's support, as before this series,
and opens nothing.

* fix(native-chat): a read waiting on the session lock opens nothing once quit began

The accessor checked for quit before queueing the open, so a read queued behind a session task ran
its open after teardown had begun and indexed a journal no teardown step would close. The check now
runs at the open itself.

* fix(native-chat): read a failed resume's chat before calling it retryable

Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the
restart, read from its journal. The failure list read it only for a chat already open, so once the
idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did
nothing. The list now opens the failed chats first, as the offer list does.

* test(native-chat): type the provider event sink the settlement test reaches for

* fix(native-chat): say the structured read keeps trying only where it does

The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an
untranslated fallback whenever the read error had no text, and the empty state prefers any message.
The view state now leaves the message out, so the structured pane shows that line and the
terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an
error frame, so it no longer makes the claim.

* test(native-chat): await the send's settlement instead of polling for the start

The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a
loaded machine outran. They now await the host's own settlement of the message.

* fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations

The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than
a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now
dated by the resume action.

Telling a rejected continuation from the user's own message read the operation ledger, whose rows
expire after about a day; after that a failed resume stopped being retryable. The offer now
records the continuation each action sends on its own capsule entry, bounded to the newest 16, so
the ids end with the offer. The ledger read is deleted.

* fix(orchestration): route no mail to a structured worker its orchestration released

A structured worker is routed on ownership, and a resting worker's lease is released, so ownership
held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found
at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one
restarted its agent. Routing now also reads the orchestration's own resource row: once it is
released, direct mail, group addressing and worker-show's addressable answer drop the worker, as
they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored.

* fix(native-chat): a failed retry names the user's prompt, not Orca's continuation

A resume's continuation is written to the chat before its start, so after a failed attempt the chat's
newest user message is that rejected continuation. A second failure then showed Orca's own restart
text as the chat's prompt. A retry now keeps the prompt its first failure named.

* fix(orchestration): read the released row optionally, as the authority does

worker-show's observation called the row lookup directly, which a runtime double without it threw on
and failed the structured tab-retirement release.

* fix(native-chat): the status bar drops a restart offer the chat moved on from

The renderer re-read the host's restart offer only when a failed chat showed activity, so after a
message withdrew a pending offer the host answered no chats while the status bar kept counting one,
and clicking it opened nothing. The same watch now covers pending offers: a status change in an
offered chat asks the host again, once.

* test(native-chat): a roster of idle or finished children does not keep an agent awake

The sweep reads owed background work through the shared child-work liveness that upstream's
release clock adopted; a child that went idle or finished is not work the agent still owes.

* fix(orchestration): a task dispatched into a resting structured worker keeps it running

The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal
resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a
dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while
that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's
process incarnation now counts, derived from the existing rows.

* docs(native-chat): comments stop describing the hold this PR removed

Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that
pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive
subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it.
Comment-only.

* fix(native-chat): a restart offer keeps the start its own continuation made

Whose start ended an offer was decided at read time, from whether the offer's continuation was
still the queued message. Once the provider refused that continuation, the child it had started
read as someone else's start, so the offer ended and its failure showed no Retry. The delivery
loop now records which queued message a start is for on the in-memory child, and the child's end
carries it; the offer counts a start as its own when that message is one of its continuations.

* fix(native-chat): an agent gets a full idle window after its owed work ends

The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or
dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can
read done before the lead's wake-up turn writes anything, and stopping in that gap loses the
wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full
window afterwards, as the release clock it replaced did.

* test(claude): the options-read fixture runs a live child

The fixture marked its conversation running with a hasProviderChild field the
session type does not have, so the read took the at-rest path and refused a
session with no record. It now carries a child, which is what the read checks.

* test(native-chat): host tests reach its collaborators through a typed seam

The rest-test rig and three test files read the host's private members with
Reflect.get and cast the result. The host now exposes one test-only accessor,
collaboratorsForTests(), and the subscribers class a subscriberCountForTests()
beside its existing retainedActivityCountForTests(), so the tests are checked
against the real types and the casts are gone.

* refactor(orchestration): one owner answers a structured worker's custody

Routing, group addressing, worker-show and the idle sweep each composed their own reading of
whether orchestration still holds a structured worker, so each new obligation or retirement state
had to be added to every reader. structured-worker-custody now derives both answers from the
worker-terminal list state coordinators see in worker-list: addressable is owned and not released,
and owed work is an active custody or an unsettled task dispatched to the same incarnation. The
owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup
already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests.

* refactor(orchestration): owed work is an open dispatch on the worker's incarnation

A supervised worker's own dispatch context stays open exactly while the worker is active, so the
separate active-custody branch only repeated it. Owed work is now one fact, which also states the
policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are
written once at the top of the module.

* fix(native-chat): a restart offer knows its continuations by a tag in their id

The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running
action's id in memory. Both could disagree with the journal: past the cap an old rejected
continuation read as the chat moving on, and a crash during a retry restored the failure's older
entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer
(its teardown and chat), then the action's own part, so any continuation of this offer, queued or
rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and
the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own
continuation the start was for, read against the stored marker.

* test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait

The tui-idle probe reads through readTerminal, which now awaits the structured
worker check before the PTY read, so the probe's snapshot request starts a
microtask later. vi.waitFor missed it on its first check and polled again at
50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot
then resolved after the wait had already timed out, so the test passed without
judging it, and the rejection landed before any handler was attached. Vitest
reported that as an unhandled error and failed the shard.

Polling every 1 ms sees the request within a few ms, so the snapshot is judged
while the wait is still pending.

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* fix(native-chat): the idle sweep reads owed work every tick

Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it
refreshed the clock at most once a window. Work that ended just before the next read left the
agent to be stopped at that read, moments after the work ended, which is the gap the refresh was
meant to cover. The sweep now reads owed work on every tick for a started agent, so the window
always runs from the last tick that saw work owed.

* fix(native-chat): a continuation handed to the agent stays sent

The offer read its own continuation as not reaching the agent while its dispatch was pending, which
also covered one already handed over and still unanswered. When the wait for that answer ended first,
the failure it filed read as retryable, and a retry sent a second continuation to an agent that may
have acted on the first. Only a continuation still queued, or rejected, is now read as unsent.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): the interrupted create's own retry continues again

The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its
own operation id, with a fresh start whose result nothing read. That fresh start passes with the
released-reservation continuation deleted, so the case the fix exists for went untested. The retry
and its assertion are main's again.

* docs(native-chat): three comments that still had views starting agents

A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction
left alone would refuse every send, so no agent would ever start to finish it; and a current host
raises the unattached read refusal only once quit began, with the attach window belonging to an older
host.

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* test(native-chat): a reader's open settles the turn a failed exit settlement left running

An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now
settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle
sweep closed, and a read that opens the chat before the restart restore reaches it.

* test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner

A subscription reads the conversation before it returns, so under load the two views took longer
than the create child's 300 ms start, which then exited before the test checked that it had not.
The child now takes a second to fail.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn

On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the
dead process's lease still reads live. The open settles the turn it left running anyway, and the
restore that follows finds it settled.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* docs(native-chat): drop the removed dispatch hold from six comments

A worker's session no longer takes a dispatch hold, and no release clock
rests a chat by visibility; the agent-launch comments, the abandon test,
the teardown test and the refusal census still said so.

* test(native-chat): rest the owner-status chat through the idle sweep, not a hold

The activation-gate test from #22808 put its chat at rest by holding and
releasing it, and passed the release-clock grace. This branch deleted both,
so the case threw before it reached its assertions. It now moves the host's
clock past the idle window and lets the sweep stop the agent and close the
conversation, then asserts the same owner answer and activation gate.

* fix(native-chat): show the structured pane's retrying line when a read fails

The read transport always hands the pane the host's words, so the error
state's "Orca keeps trying to load it" line, which showed only when there
were none, was never seen: the pane showed the host's text twice, as its
subtitle and on the status line under it. The structured pane now always
says its read keeps retrying, and the host's text stays on the status line.
The terminal-backed chat is unchanged.

* test(native-chat): wait for a send's background start before the refusal oracle removes its store

An accepted send wakes the delivery loop, which starts the agent in the background. The oracle's teardown disposed the loop but did not wait for that start, so its lease write could create a temp file in the store directory while the directory was being removed, failing the test with ENOTEMPTY about one run in four. The teardown now drains tracked starts before it closes the journals.

* fix(native-chat): a start a message waited on gets one failure row, the delivery loop's

When a queued message's start failed, two writers could report it under the same row: the delivery loop, when the adapter settled the start without proving it, and the exit settlement, when the child's exit landed. The last one won, so the chat's row could name a different cause than the one the message was rejected with, or be written twice.

The exit settlement now writes the start's row only when no message is queued and the loop has not already recorded that start. A start for a command, goal change or rewind, with nothing queued, still gets its row from the exit.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-27 23:46:14 -07:00
Brennan BensonandClaude 85067494a1 fix(native-chat): a request that failed reads as failed (#22944)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* fix(native-chat): a request that failed reads as failed

A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.

The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): a verdict change republishes the mobile status projection

* refactor(native-chat): the store's retention trigger keeps its flag compare

A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.

* test(native-chat): a user message the provider journaled keeps its session listed

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a late provider-session update keeps a failed recovery record failed

A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* test(native-chat): the terminal-bell check asserts the renamed verdict field

The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.

* fix(native-chat): a failed turn ranks like a completion for attention

Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.

The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.

* fix(native-chat): a failed main agent reads failed while its subagents still work

The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.

Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.

worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.

* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it

The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.

* docs(native-chat): the status-store listing rule names provider-journaled user messages

* fix(native-chat): a refused send notifies failed through the completion feed

The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.

* fix(native-chat): every copy of a row carries the main agent's own status

History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.

- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
  matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
  malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
  as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
  rebuilding one; the sync key and history equality compare it.

* test(native-chat): pin the worktree ps verdict across host and phone versions

Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.

* test(mobile): name the parity table's row for its role

* fix(native-chat): a request that settles while the user is asked something notifies once

The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.

The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.

* fix(native-chat): the completion says when the user is being asked

A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.

The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.

* fix(worktree-status): a departed agent's failure yields to live work on the worktree card

A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.

* docs(agent-status): a departed agent's failure ranks below live work on the worktree card

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(cross-version): load the phone row readers without mobile's toolchain

Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.

The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.

* test(cross-version): keep the checkout path-guard message and justify the copy import's cast

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* fix(native-chat): a send the provider never received after a restart has no verdict

Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-27 22:23:49 -07:00