Files
orca/mobile
Brennan BensonandClaude 8a38a7a3e6 feat(mobile): mid-turn messages wait as cards above the phone composer (#23736)
* 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.

* feat(mobile): render host-queued drafts as cards with Send-now/Edit/Delete, and restore withdrawn text once across reload

* feat(mobile): /clear withdraws queued drafts on capable hosts, and reason markers map to readable copy

* fix(mobile): an empty queued-draft publication never churns the held empty list

* fix(mobile): relaunch recovery replays results only — a persisted Stop or /clear never re-executes

* fix(mobile): a relaunch never reissues a Stop or /clear, a lost withdrawal answer is re-asked, and the send journal stays readable by older builds

Relaunch recovery no longer recomputes the host's replacement-session id: that
copy of the host's derivation had already drifted (full digest vs the host's
40-hex slice), so it could never fire. A previous process's Stop and /clear
handles are now released once per pane per process — the host keeps
unwithdrawn drafts visible as cards — and only an Edit is finished. The sweep
runs once per process in one serialized journal step, so a remount can no
longer drop the handle of this process's own in-flight Stop and lose its text.

A withdrawing Stop, /clear or Edit whose answer is lost is re-asked under the
same operation id (bounded): once the host committed, the card is gone and only
that answer carries the text back.

`delivery` is no longer a persisted send-journal field — an older build (or an
older host's page, which reads the same key) would find the strict schema
unreadable and refuse every structured send. It is part of the intent key
instead; the immediate key is unchanged, and a retained entry under either key
replays exactly as first sent.

An identical send whose retained id replays as a withdrawn draft goes out under
a fresh id instead of vanishing. Also: the send callback is stable across
streamed frames, card busy state is per card, card actions carry a button role,
and render-time ref writes moved into layout effects.

* fix(mobile): a lost-answer queued send never reads as unconfirmed, the restore journal works inside the page, and its handles die

- A send whose answer was lost but which the host holds as a queued-draft card no longer warns "Delivery unconfirmed" after 20 s: a card that was not on screen at send time with the send's text counts as delivery, like the transcript echo does.
- The queued-draft restore journal key joins the page storage allowlist and writes through the mirrored path, so a Stop, /clear or Edit from the page-served session screen keeps its replay handle; a write the store dropped without rejecting is refused up front so the caller restores directly instead of holding a handle no store kept.
- Seeing a published draft named by a journaled send's operation id spends that entry: the draft is the host's receipt of the send, so a draft later withdrawn elsewhere no longer leaves an entry nothing will ever settle.
- Withdrawn text is restored at most once even if removing the journal entry fails after the restore ran.
- A returned card offers Edit, as on desktop.
- Test outcome annotations use MobileNativeChatSendOutcome, which now includes 'queued'.

* fix(mobile): withdrawn queued text reaches its pane even after the screen closed, and a lost answer is re-asked in the background

A Stop, /clear or Edit answered after the session screen closed wrote into a
composer that no longer existed, while the journal entry was removed: the text
was lost. Restored text is now owed by composer scope and the pane's composer
takes it once whenever that scope is active.

A lost answer was re-asked inline up to three times, each with the full command
budget, so a /clear could hold the composer for minutes. The first answer now
returns at once and re-asks run in the background on the 1 s / 2 s / 4 s
schedule with a short budget each. Restoration stays once per operation id,
also when a retry of the same id races the re-ask.

* refactor(mobile): queued drafts stay host-owned — Stop and /clear never pull text back to the phone

A Stop or /clear no longer withdraws queued drafts and ships their text back
over the wire. Cards stay on the host as paused cards (captioned 'Paused —
sends after your next message') with Send / Edit / Delete, identical on every
device, and the host carries them across a /clear itself. Edit copies the
card's shown text into the composer first and then issues a plain Delete, so
no RPC outcome can lose it; a failed Delete leaves the card visibly beside the
copy.

With no text in flight there is nothing to make exactly-once: the AsyncStorage
restore journal and its page-storage allowlist entry, the background re-ask
schedule, the relaunch release/replay pass, the restore-once guards, and the
owed-composer-text buffer are all deleted. Cancel and conversationCommand go
back to main's plain requests, so old and new hosts see one Stop and one
/clear behaviour.

Kept: capability gating, the cards and captions, Send-now, the 'queued' send
outcome, the queued-card settlement of unconfirmed sends, the delivery-in-key
send-journal fix, and the queuedMessages frame tracking.

* fix(mobile): only a 'stopped' hold promises "sends after your next message"

The host now publishes pausedReason 'stopped' for Stop, /clear-carry and
restart holds. An absent or unknown marker is a hold whose release rule this
build does not know, so it reads as a plain "Paused" instead of promising
that the next message resumes it. Drops an unused type re-export.

* fix(mobile): a paused queued card's action reads Send, not Send now

"Send now" names jumping the running turn. A paused card (a Stop, /clear
or restart hold, or a failed conversion) waits on no turn, so like a
returned card its action and accessibility label say plain Send, matching
desktop.

* test(mobile): find the queued card's Text nodes by name so the test typechecks

* fix(mobile): retire a replayed send the host refuses by shape; 44pt queued-card targets

A retained delivery send that an older host's strict schema turns away can never be
accepted, so keeping its operation id refused every later send of the same text. The
host's own request refusal now retires it; any other doubt still keeps the id.
Queued-card actions now touch as 44pt targets while drawing as a 32pt text row.

* fix(mobile): a waiting queued card's action reads Steer, as on desktop

The same action read "Send now" on the phone and "Steer" on desktop. Its accessibility
label now matches the desktop hint: "Send now without waiting for the turn to end".
A paused or returned card keeps plain Send.

* fix(mobile): focus the composer after Edit moves a queued message into it

Edit copied the card's text into the composer but left it unfocused, so the user had
to tap the field to keep typing. The composer now takes an input ref and the chat view
focuses it on the next frame after Edit.

* fix(mobile): only a schema rejection retires an in-doubt send; an auth refusal keeps its id

An unauthorized answer to a replay says nothing about whether the first
attempt was delivered, so retiring its id there could send the message twice.

* 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(mobile): a returned queued card reads its typed rejection like a rejected send

The card's label called `dispatchRejectionReasonIsInternal`, which main
removed when rejections became typed facts, so labelling a returned card
threw. A host-restart rejection's reason is also a sentence now, with no
marker for the old string check to recognise.

The label now comes from the shared words a rejected send gets
(`structuredAgentSessionRejectionParts`, 'composer-send'), given the card's
`returnedRejection` fact and falling back to `returnedReason` when a host wrote
none. A Stop withdrawal, read through `dispatchWasWithdrawn` with the fact,
keeps "Held back by Stop — Send to retry"; a provider's words show only where
their audience is the person; a fact kind this build cannot place reads as not
sent rather than trusting the sentence beside it.

* fix(mobile): a returned queued card is worded as the desktop card words it

The card has its own Send, so its words leave out sending again, the way the
desktop card passes retryControl to the shared attempt-failure words. A Stop
withdrawal reads "Stopped before it was sent", the desktop caption.

* fix(mobile): a returned card with a fact this build cannot place shows the host's sentence

The host writes a rejection's reason as a sentence for a person, so when a
newer host's fact kind cannot be placed, that sentence is the best words
available. The card now leaves it to the shared attempt-failure words, as the
desktop card does; an old internal marker still maps to generic words there.

* 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(mobile): a returned card's reason reads whole, and Edit never deletes text it did not copy

A returned card's label was capped at one line, but its reason often reads
only at its end ("... does not support the image type .bmp in a steering
message."). It now wraps; waiting and paused holds stay one line.

Edit copied a card's text into the composer and then deleted the card,
whether or not the copy landed: before the composer mounted, or for an empty
card, the append was a no-op and the delete still ran. The append now reports
whether it copied; Edit stops before Delete when it did not, and focuses the
composer only after a copy. When Edit's Delete loses to the drain, the phone
says "Already sent — your text is still in the composer.", as the desktop
does, so the copy is not sent a second time.

The controller now carries the queued controls as one field, which keeps it
within max-lines.

* fix(mobile): a queued send's replay never vanishes or paints an unretired bubble

A replay answered `withdrawn` is resent under a fresh id only when the
retained id could be released. When the release failed, the answer fell
through as `queued`, which shows nothing, and the text was gone. It now reads
as rejected, so the text returns to the composer.

A replay answered `queued{state:'dispatched'}` was mapped to `accepted`. The
host answers that way only when it cannot find the submission the draft
became, so no echo would retire an optimistic bubble. It now maps to
`queued`, which shows nothing; the transcript or the card owns the text.

* fix(mobile): a queued card hides once its message arrives, and a failed pause says tap Send

A waiting card whose submission has already arrived is hidden, as the desktop
card projection hides it: on a multi-page catch-up the shrunk list rides only
the final page, so the bubble and the card showed together. Returned cards
always show.

The send-failed pause reads "Couldn't send — tap Send to retry".

* 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

* test(mobile): compare the queued card's Text nodes by name so the test typechecks

* fix(mobile): a draft a Stop requeued stays visible beside its rejected first submission

A Stop that withdraws a consumed draft before delivery puts it back to
waiting under its own id, while its first submission stays in the journal as
rejected. The card filter hid any waiting draft with a same-id submission, and
the transcript hides rejected submissions, so the requeued text could not be
seen, edited, deleted or sent, and later drained unannounced. A rejected
submission no longer hides a card: beside a waiting draft it can only mean a
requeue, since a refusal returns the card instead.

* fix(mobile): a send record storage will not clear never blocks sending that text

A replay answered `withdrawn` is resent under a fresh id only after its
saved record is cleared. While storage kept failing to clear it, every send
of that exact text replayed the old id, was answered `withdrawn`, and came
back rejected, so the text could not be sent until storage recovered. The
resend now goes out once under a fresh id that bypasses the saved record, and
the failed clear is reported through the send's error path.

* fix(mobile): a send record whose draft went out under another id is spent

A send whose answer was lost keeps its record, so the same text replays the
same id. When a Stop requeued that draft and it later drained under a fresh
id, nothing could clear the record: the draft was no longer published, and no
submission carries the old id. Every later send of that text replayed it and
waited for a settlement that would never come.

The host answers that replay with the submission the draft became, under a
different id than the one replayed. That answer now spends the record. Which
send the phone meant stays unconfirmed, as for any retained replay of a live
send.

* 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(mobile): relate a queued draft to its hand-off only through queuedMessageId

The phone matched a draft to the submission it became by comparing ids. The
host now hands off every draft under a fresh submission id and stamps that
submission with `queuedMessageId`, so the ids never match and the link is the
only relation.

- A waiting card hides exactly when a submission names it as its
  `queuedMessageId` and was not rejected; a rejected hand-off is what sent the
  draft back. A direct send sharing the card's id hides nothing.
- A saved send record is spent when its id is a published draft id, or when
  any submission names it as its `queuedMessageId`, in whatever state. This
  clears a lost send that a Stop requeued and the drain later sent under a
  fresh id, from the live submissions stream.
- The replay rule that spent a record when the answer's submission id differed
  from the replayed id is gone. A replay answered by the hand-off reads as
  unconfirmed, as any retained replay of a live send does, and paints no
  optimistic bubble; the stream spends the record once it carries the link.

* 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(mobile): a replay the host answers with its draft's hand-off spends the send record

A retained send replayed after its queued draft was handed off is answered by
the hand-off, which names the replayed id as its `queuedMessageId`. That
answer fell to the retained branch and kept the record, leaving only the live
stream to spend it. The stream can miss the hand-off for good: a reconnect
snapshot covers only the latest page, and submissions ride only with their
items. Every later send of that text then replayed and read "Delivery
unconfirmed" forever.

The answer states the link, so it now spends the record directly, checked
before the rejected and retained branches. Which send the phone meant stays
unconfirmed, as for any retained replay of a live send.

* fix(mobile): a resend past an uncleared send record replays its own id on a retry

When storage could not clear a withdrawn send's record, the phone resent the
text under a fresh id with no record. If that resend's answer was lost and the
user sent the text again, the old record replayed, came back withdrawn, and
the phone minted another fresh id: a second delivery if the first resend had
landed.

The resend's id is now kept in memory for the app run, by operation key, so a
retry replays it; it is forgotten once the host answers it as spent. The
"couldn't update its record" notice now shows only when the resend is known to
have gone out (accepted or queued), never for an unconfirmed one.

* 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.

* test(mobile): a withdrawn replay of a pruned deleted card, position 0, still frees the text

The host now answers a replay of a deleted card whose row was pruned with
queued{state:'withdrawn', position: 0}. The phone reads only the state, so
the next identical send still goes out under a fresh id; the test now uses
that receipt.

* fix(mobile): a bypassed resend's id survives storage recovering, and a malformed answer spends nothing

The remembered id of a resend sent past an uncleared record was consulted
only while the record still could not be cleared. If storage recovered
between that resend's lost answer and the retry, the record cleared, the
normal path minted a fresh id, and the message could be delivered twice. The
retry now hands the remembered id to the new saved record, which replays it,
and memory lets it go. The id is also keyed by the operation key the saved
record matched, not the delivery asked for now, so a capability change in
between cannot mint a fresh id.

A send answer is read as its draft's hand-off only when it carries a
submission, so a malformed answer with neither a submission nor an id no
longer spends the saved record.

* refactor(native-chat): name the queue's pause-lift for what it releases

* refactor(mobile): name a schema-refused replay for what the host decided

* chore(native-chat): one import of the mutation helpers

* refactor(mobile): build the queued-card slot outside the chat view

MobileNativeChatView was over its 400-line limit with the queued cards wired
in. The cards and the composer ref their Edit focuses are now built by
useMobileNativeChatQueuedSlot in the overlay, and the view only places the
cards and hands the ref to the composer.

* test(mobile): say why the overlay test's partial controller is safe

* 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.

* feat(mobile): a paused queue shows one header row with Resume, and its cards keep Steer

The host now pauses the whole queue after a Stop, a restart or a /clear, and
publishes that pause once beside the list instead of on each card. The phone
followed the old per-card pause: each card read "Paused — sends after your
next message" and its Steer turned into Send.

- One header row above the cards says why the queue is paused ("Queue paused
  because you interrupted", "... because Orca restarted", "... after you
  cleared the conversation"; an unknown reason reads "Queue paused") and
  offers Resume, which calls agentSession.queuedMessagesResume through the
  same mutation path as Delete, so a refusal reaches the error banner. It
  shows only while there are cards.
- Cards under a paused queue keep Steer, Edit and Delete and read "Queued",
  promising no send time. A card whose own send failed still reads "Couldn't
  send — tap Send to retry" with Send, and returned cards are unchanged.
- Steer's accessibility label is "Submit without interrupting the model".

The pause is kept per session beside the list and changes only on frames that
publish the list. The row lives in the queued-cards component, so the chat
view gains no lines. The queued hook tests' published shapes move to a
fixture module to keep that file within its line limit.

* test(mobile): the paused-queue header words the host's 'cleared' reason

The host now sends queuePause.reason 'cleared' after a /clear carries the
queue over. The header already worded it; the tests now pass it as the
host's typed reason rather than as an unknown one.

* test(mobile): import the subscribe-event type the queued hook test names

* 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.

* fix(mobile): the paused-queue row keeps Resume's whole target and is announced

- The row no longer pulls itself past the top of the card list with negative
  margins. The list has no padding there, and Android drops touches outside
  a parent, so part of Resume's 44pt target could not be tapped.
- The row is a polite accessibility live region, as the app's other notices
  are, so a screen reader hears that the queue paused.
- A pause published with no reason, or one this build does not know, now
  counts as a pause. The reducer compared reasons only, so it kept a previous
  "not paused" for it; it now reads "Queue paused".
- The card list is keyed by conversation, so a Resume still in flight in one
  chat no longer disables Resume in another.

Tests cover a double tap, a lost Resume answer re-enabling the button, the
row's layout and live region, the conversation keying, a reasonless pause,
and a paused queue's failed-send card and prompt wait.

* 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(mobile): the paused-queue header shows only while Resume would send a card

The host publishes a pause while any card is waiting with no hold of its own,
but it does not exclude cards behind a returned card, which the drain never
sends. With only such cards, or only returned or failed ones, the header
offered a Resume that would do nothing. The header now shows only when a
waiting card with no hold of its own sits ahead of any returned card, as on
desktop.

* 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

* test(mobile): a queued card's Steer groups under the running turn

With turn facts read from the turn record, a draft Steer hands over while a
turn runs is scoped by the host to that turn, under a fresh submission id
that names the card. On the phone it joins that turn's group, gets no bar of
its own, and stays live with it.

* feat(mobile): queued messages sit in one compact box, as on desktop

Each queued message was its own tall card with a "Queued" header and a row of
Steer / Edit / Delete text buttons, and the paused-queue line floated above
them. The phone now draws the desktop's layout: one bordered box whose first
row is the pause line with Resume (only while paused), then one compact row
per message with a leading queue or alert icon, the text (up to two lines,
since a phone has no hover title), a caption only when there is something to
say, and Steer (Send for a returned or failed card), a trash button, and a
"..." menu holding Edit message.

The card model's label becomes a nullable caption with the desktop's rules
(no caption for a plain wait or the queue's pause) plus a needsAttention flag
for the alert icon. Touch targets stay 44pt inside their rows.

* test(mobile): stub the queue's action sheet in the chat view tests

* test(mobile): move the chat view's turn-status wiring tests to their own, type-checked file

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-29 18:43:34 -07:00
..

Orca Mobile

React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.

Local development uses two processes:

  • Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port 6768.
  • Expo Metro from mobile/. This serves the React Native app on port 8081.

Unless a command says otherwise, run mobile app commands from the mobile/ directory.

Prerequisites

  • Node.js 24+
  • pnpm
  • Xcode and/or Android Studio tooling for simulator or device builds
  • Expo Go on your phone, or a development client build when native modules are needed
  • Phone and desktop on the same LAN when testing a physical phone

Start Desktop Orca

From the repository root:

pnpm install
pnpm dev

Confirm the mobile RPC server is listening:

lsof -nP -iTCP:6768 -sTCP:LISTEN

Restart pnpm dev after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle.

Start The Mobile App

cd mobile
pnpm install
pnpm start

Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android.

For a native dev-client build:

pnpm exec expo run:android
pnpm exec expo run:ios
pnpm start --dev-client

Pair With Desktop Orca

  1. Open Orca desktop.
  2. Go to Settings > Mobile.
  3. Scan the pairing QR code from the mobile app.
  4. Confirm the mobile host endpoint is ws://<desktop-ip>:6768.

For the Android emulator, use ws://10.0.2.2:6768. For a physical phone, use the desktop LAN IP, for example ws://192.168.0.179:6768.

If the phone has a stale host entry, remove it from the app and pair again.

Development Paths

Android Phone

  1. Install Expo Go from Google Play
  2. Run pnpm start, scan QR with Expo Go
  3. For native modules: pnpm exec expo run:android
  4. Run with pnpm start --dev-client

iOS Simulator

  1. Install Xcode from the App Store
  2. Run pnpm start --ios to open in iOS Simulator

Physical Phone Debugging

The phone can be inspected through the connected device tooling:

orca snapshot --json
orca click --element @e3 --json
orca fill --element @e1 --value "ls" --json
orca screenshot --json

Use snapshot first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs terminal.subscribe.

Terminal Streaming Repro Without A Phone

Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:

cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64>

You can pass a worktree selector as the third argument:

pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "path:/absolute/worktree/path"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "name:my-worktree"

The expected result includes:

streamSawMarker: true
readSawMarker: true

If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or TerminalWebView readiness/queueing path.

Terminal Color Repro Without A Phone

Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run:

cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \
  <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"

The script captures terminal.subscribe snapshots in an A → B → A sequence and writes raw snapshots to mobile/terminal-color-repro/. If the two A snapshots have different sgrColor counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering.

Validation

Run these checks before committing mobile terminal changes:

cd mobile
pnpm exec tsc --noEmit
pnpm run check:tests-typecheck
pnpm lint
cd ..
pnpm typecheck:node

tsc --noEmit reads tsconfig.json, which excludes test files so Metro never bundles them. tsconfig.test.json puts them back, and pnpm run typecheck:tests shows their errors in full. check:tests-typecheck is the gate over it: a ratchet against tests-typecheck-baseline.txt, the 127 test files that do not typecheck yet. It fails when a file that checks today stops checking, and when a baseline entry starts checking (prune it with node scripts/check-tests-typecheck-ratchet.mjs --prune). The list may only shrink.

The same gate censuses the program first: every *.test.ts(x) on disk must be in it, or named in the script's TESTS_OUTSIDE_PROGRAM with a reason. Without that, a test excluded from tsconfig.test.json — or a Foo.test.tsx shadowed by a Foo.test.ts beside it, which a wildcard include drops for the higher-priority extension — would leave the ratchet silently.

Protocol Version Compatibility

Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on status.get so a genuinely incompatible combo can hard-block instead of silently misbehaving.

Constants live in two files (Metro can't resolve outside mobile/):

  • src/shared/protocol-version.ts — DESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION
  • mobile/src/transport/protocol-version.ts — MOBILE_PROTOCOL_VERSION, MIN_COMPATIBLE_DESKTOP_VERSION

Today all four are set so evaluateCompat always returns { kind: 'ok' } — nothing blocks. The wire format is in place to flip a switch when needed.

When to bump

Bump DESKTOP_PROTOCOL_VERSION (and the mobile mirror MOBILE_PROTOCOL_VERSION when relevant) for breaking changes:

  • Removed RPC method or required parameter that mobile uses
  • Changed meaning (units, nullability) of an existing field mobile reads
  • Changed encryption, framing, or auth handshake

Do not bump for additive changes:

  • New RPC methods
  • New optional fields on existing methods
  • New event types in terminal.subscribe

Set MIN_COMPATIBLE_MOBILE_VERSION (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for MIN_COMPATIBLE_DESKTOP_VERSION from the mobile side.

When a verdict is blocked, mobile/src/components/ProtocolBlockScreen.tsx renders a screen pointing the user at the update that clears it. When mobile is too old it opens the newest release if the installed app's update check knows one, otherwise the App Store (iOS) or GitHub Releases (Android). When desktop is too old it opens GitHub Releases.

To exercise the block screen locally: set MIN_COMPATIBLE_DESKTOP_VERSION = 999 in mobile/src/transport/protocol-version.ts, rebuild, pair to any desktop. Revert before merging.

Mock Server

Develop the mobile app without a running Orca desktop instance:

pnpm mock-server           # starts mock WebSocket server on port 6768

Connect from the app using endpoint ws://localhost:6768 and token mock-device-token.

Environment variables

  • MOCK_NATIVE_CHAT=1 — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.
  • MOCK_CHAT_AGENT=omp — with MOCK_NATIVE_CHAT=1, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omits transcriptPath to exercise legacy-hook readability discovery; current OMP hooks may report a path.
  • MOCK_SERVER_KEY_FILE — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.

Scenario control files

Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.

  • MOCK_SEND_MODE_FILE (default orca-mock-send-mode in the system temporary directory) — accept (default) accepts the send, error fails it with mobile_input_floor_unavailable, anything else reports the send as rejected.
  • MOCK_TERMINAL_LIST_MODE_FILE (default orca-mock-terminal-list-mode in the system temporary directory) — omit returns an empty terminal list, other returns a list that omits the chat handle, anything else lists it.
  • MOCK_TERMINAL_STREAM_MODE_FILE (default orca-mock-terminal-stream-mode in the system temporary directory) — dead answers a subscribe with subscribed then end (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.

Connecting to Real Orca

  1. Start Orca desktop with WebSocket transport enabled
  2. In Orca, go to Settings > Mobile and scan the QR code with this app
  3. The QR encodes the connection endpoint, device token, and TLS fingerprint

Project Structure

mobile/
├── app/                   # Expo Router screens (file-based routing)
│   ├── _layout.tsx        # Root layout with navigation stack
│   ├── index.tsx          # Home screen — paired hosts list
│   └── pair-scan.tsx      # QR code scanning screen
├── src/
│   ├── terminal/          # Terminal WebView and xterm bridge
│   └── transport/         # WebSocket RPC client
├── scripts/
│   ├── test-subscribe.ts  # Desktop streaming repro without a phone
│   └── mock-server.ts     # Standalone mock WebSocket server
└── assets/                # App icons and splash screen