Commit Graph
3 Commits
Author SHA1 Message Date
Brennan Benson 0f9f199322 fix(native-chat): no saved outbox on the desktop; one send at a time, and the host owns what it accepted (#25959)
* fix(native-chat): the host owns the send queue; the window keeps no saved outbox

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Keeps NativeChatStructuredSession.tsx within the file length limit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A paired server checks every stored file a message names when it admits it,
and refuses the whole message before recording it when one has expired. The
sender hands such a message back to the chat's draft, file included, with the
refusal's words, whether or not a view shows the chat, so it can be removed
and attached again. Ported from the saved-outbox test that came with
attaching files to a structured chat on a paired server.
2026-10-07 14:07:30 -07:00
Brennan Benson 97fa6aee74 fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat (#24710)
* fix(native-chat): keep a message the host accepted then rejected in the desktop chat as not sent

Draw it in place from the host's history, so a crash that loses the outbox no
longer makes it vanish. A later copy of the same body supersedes it; the outbox
row wins while it holds the message; the phone is unchanged.

* test(native-chat): pin the same-id rule apart from the body match

* test(native-chat): type the rejected-in-place fixture body as a text block

* fix(native-chat): let the host's row own a message it recorded and then rejected

Once the host's journal records a send as rejected, the desktop outbox lets it
go, as it already does for delivered and Stop-withdrawn sends: the host's row
shows it as not sent, with the host's reason and no Retry. The outbox keeps
only sends the host refused before recording them, which keep their Retry.
A send whose own reply says it was rejected is drawn by its outbox entry, with
no Retry, until the journal carries the row; a copy left by an earlier session
is dropped when the chat opens.

- the transcript no longer hides a host row behind an outbox entry with the
  same id or the same text; those rules and their cache are gone
- a rejected message the queue holds (a draft's hand-off, or a live card under
  its id) is drawn as its card, not as a row
- a later copy of the same text hides a rejected row only when it was sent
  once the rejection was known, so a deliberate repeat stays
- delivery notices read the same visibility rule as the transcript; a chat
  whose only rejection a Stop withdrew no longer rebuilds them per batch
- the body fingerprint helper goes back to the host, its only user

* test(native-chat): keep one row when copies of a rejected message share an instant

* refactor(native-chat): let the host's notice replace the outbox's under the same id

* test(native-chat): pass the queued card ids in the tool-stream cost transcript

* fix(native-chat): keep the host's record as what lets a rejected message go

- the outbox no longer drops a host-rejected message when a chat opens; the
  reconcile lets it go once the journal's submissions say it was rejected, and
  that drop is written to storage, so nothing reads as still owed
- a message the host rejected while the chat watched waits for its journal row
  with no Retry; one read back from storage with no row loaded keeps its Retry
  under a new id, since the host may have lost it
- the delivery notices keep the same map and notice objects across a batch that
  words every row the same, so a submission batch re-renders no row
- a rejected command such as /compact stays hidden: its own reply reports it
- the desktop transcript requires the queued card ids, with a controller-level
  test that a card holding a rejected message keeps its row hidden

* fix(native-chat): draw a queued message where the host rejected it

A message accepted to hand over later and rejected before any handover now sits
at its rejection, as a handover places one: what the agent did while it waited
happened before it, and the newest history page holds it. One handed over, or
dispatched as it was recorded, keeps its place. An older host does not move it,
so it stays at its submission, still drawn.

A failed start now rejects the queued messages and writes its row in ONE
journal append, the messages first: no reader ever meets one without the
other, and the messages still draw above the row that says why.

* test(native-chat): pin that rows written together roll back together

* fix(native-chat): draw every rejected message where it was rejected

Not only a queued message: one handed over into a turn and then rejected, or
sent directly and rejected, also sits at its rejection, in no turn. A message
in doubt stays where it was, a plain bubble: it may have reached the agent.

* fix(native-chat): decide a rejected message's Retry from the host's stored fact

- a message the host recorded and then rejected has no Retry on any mount,
  however that mount learned of it, and a Dismiss that clears it from storage;
  a send refused before the host recorded it keeps its Retry
- the rule that keeps a rejected command such as /compact out of the
  transcript moves into the one visibility function rows and notices share
- the outbox state docs say what lets a recorded message go: the client holding
  its rejected submission, whose row the host places at the rejection

* fix(native-chat): write no start-failure row when a Stop withdrew every queued message first

* test(native-chat): pass the Dismiss action in the delivery-notice hook tests

* fix(native-chat): keep a rejected message's outbox copy until its row loads

An older host leaves a rejected message where it was sent, which may be older
than the loaded window: the chat then holds the rejected submission but not the
row that draws it. The outbox copy now stays until that row loads, marked as
the host recorded it (Dismiss, no Retry, in the host's words), and leaves once
the page holding the row is loaded. Derived from the loaded rows each time.

Tests that label their projection as the phone's now pass the phone's own
setting.

* test(native-chat): type the outbox hook props that carry loaded rows

* fix(native-chat): write nothing when a journal batch settles nothing in the outbox

The outbox re-reads the journal on every batch since it waits for a rejected
message's row to load. Its reconcile now returns each unchanged entry, and the
list, as themselves (a message left in doubt included), so a batch that changes
nothing writes nothing to storage. The reconcile moves to its own module.

A copy the host recorded and rejected owes no delivery, so it no longer keeps a
hidden pane reading the journal.

* test(native-chat): count storage writes on the outbox's own storage object

* fix(native-chat): let a recorded rejected message's outbox copy leave on its own, with no Dismiss

The outbox copy of a message the host recorded and then rejected draws it only
while the host's row is not loaded, and leaves on the batch or page that loads
that row. It owes no delivery and offers no control: sending it again is a new
message. The Dismiss that let the user clear it is gone, from the outbox, the
notices and the session controller.
2026-10-04 14:28:10 -07:00
Brennan BensonandClaude 6b36a2c3fb feat(native-chat): mid-turn messages wait as editable cards above the composer (#23731)
* 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(native-chat): queued mid-turn drafts become editable cards above the composer

Against a host advertising agent-session.queued-messages.v1, Enter stamps the
send 'delivery: queue-if-active' (chat-wide 'Queue follow-ups' setting, on by
default) and the host's published drafts render as compact cards between the
transcript and the composer — never as transcript bubbles — with Steer
(send-now, Cmd/Ctrl+Enter for the newest), Delete, and a menu with Edit message
and Turn off queueing. Returned cards show the stored effective rejection with
the same words a rejected submission gets (a Stop-withdrawn one says so);
paused cards localize the host's typed marker, and an unknown marker reads as
a plain pause. Hold captions are derived client-side; the wire carries none.

Restore is write-ahead: Stop, Edit and a capable /clear (withdrawQueued on
conversationCommand, fingerprint-matched to the host's digest) persist their
operation identity before the RPC and append the withdrawn bodies to the
composer draft exactly once — replays answer from the durable restored record,
and a /clear's text lands in the replacement session's pane. A marker left by
a crash is RELEASED, never replayed: an unadmitted operation-id replay would
execute the command, so a reopened chat can never be cleared, nor new work
stopped, by a press from before a crash; unwithdrawn drafts stay visible as
cards. Text never duplicates: an outbox entry the host visibly holds as a
draft (same id) or answers for in withdrawnQueued retires without a local
restore, and Stop's host-side restore skips ids the outbox withdrawal already
put back.

The renderer carries the list everywhere frames flow: reducer (live over stale
history, omitted means unchanged) and the frame coalescer (latest wins, like
commands). Everything is capability-gated: an older host sees byte-for-byte
today's requests — no delivery key, no withdrawQueued, no queuedMessage RPCs.
The capability stays dark; nothing here advertises it.

* fix(native-chat): queued-draft restore survives a lost answer and an unconfirmed /clear

- A capable /clear reuses the operation id write keeps for an unconfirmed
  clear, so the next press replays it; a fresh id each press was refused by
  the host for as long as the first stayed unconfirmed.
- Edit, Stop and a capable /clear replay a lost answer (the call threw) under
  the same operation id, bounded and in-session, so withdrawn text still comes
  back after the card has gone. A refusal or fence move stays final; a crash
  marker is still only released on remount.
- Restored-id bookkeeping lives in memory beside the draft cache it guards;
  storage holds only in-flight markers, validated per element, removed when
  empty. The /clear marker is written only when the clear actually sends.
- A mid-turn queue send awaiting its answer, or already held as a draft, no
  longer paints as a transcript bubble next to its card.
- One action per card at a time; Edit/Delete hand focus to the composer.
- Revert unrelated en.json reflow.

* fix(native-chat): a lost Stop never lands on newer work; the steer chord never skips typed text

- A Stop whose answer was lost is replayed only while the turn and sends it was
  aimed at are still what is in flight; once another turn opens or a newer
  send lands (e.g. a queued message drained), the Stop is reported unconfirmed
  instead of interrupting work begun after the press.
- Cmd/Ctrl+Enter steers the newest queued card only from an empty composer;
  with text or an image in the composer it stays a plain send.
- A mid-turn queue send hides from the transcript only while it is on its way:
  from the entry the drain is stopped on (read through the drain's own rule),
  sends stay visible as bubbles beside the Retry row. A rejected entry holds
  nothing up, so what follows it still becomes a card.

* fix(native-chat): a send the host visibly holds as a draft frees the outbox's single flight

The published draft list is the host answering the send, exactly as a journal
row is: retiring the in-flight entry now also releases single-flight and voids
the unsettled reply. Before, a slow or lost reply kept the next mid-turn
message waiting, hidden (neither card nor bubble), until the RPC timed out.

* fix(native-chat): Steer hands focus to the composer like Edit and Delete

A steered card leaves the list once the host sends it; focus on its Steer
button fell to the document body, so the next keystroke went nowhere.

* fix(native-chat): a lost /clear stops replaying within seconds, so sends never wait on bookkeeping

Sends are refused while a clear settles. Each clear call can run for its full
195 s timeout, so three lost-answer replays could hold the composer for about
13 minutes. Replays now start only within 10 s of the press: a slow first call
is never followed by more, and at most one replay can outlast the window.

* refactor(native-chat): queued drafts stay paused cards; no draft text ever rides a wire answer

Stop and /clear go back to main's plain writes: the host pauses its drafts and
carries them across a clear, so nothing needs restoring and cards stay visible
on every device. Edit copies the text the card already shows into the composer
before a plain Delete, so no RPC outcome can lose it. The write-ahead restore
journal, replay loops, the Stop wrong-turn guard, and the clear replay window
are deleted with the contract that needed them. Stop's local outbox step keeps
an issued queue send whose answer is still out — the host may already hold it
as a card, and its answer settles it — so the same text can never appear twice.

* test(native-chat): drop the removed tabId option from the queued gating test

* fix(native-chat): paused cards caption per published reason; first card reaches the live region

A Stop's hold ('stopped') says it sends after your next message, a failed
consume ('send_failed') asks for Send, and an absent or unknown marker reads
as a plain "Paused" instead of promising a resume the host may not do. The
live region now stays mounted while empty so the first queued card is
announced.

* fix(native-chat): a paused or returned card's Send tooltip no longer promises to skip a turn

* fix(native-chat): show the queue follow-ups switch only when the host queues messages

The switch rendered whenever structured chat was on, even though a host that
does not advertise agent-session.queued-messages.v1 ignores the preference.
It now reads the local host's capability through the existing structured
host-capability hook and stays hidden until the host says it queues.

The copy now also says that messages with images send right away, since
image messages never queue. Updated in all six catalogs.

* fix(settings): find the Queue follow-ups switch when searching "queue"

The switch renders inside the Chat UI settings entry, whose search keywords
never included "queue", so settings search hid it. Add a localized "queue"
keyword to that entry in every locale catalog.

* 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): say why Stop keeps a dispatching queue send that is not the in-flight one

A pending answer frees single-flight but leaves the entry dispatching until its journal row lands.

* fix(native-chat): word a returned queued card from its typed rejection fact

A returned card is classified and worded exactly as a rejected submission: returnedRejection decides, returnedReason is the fallback. A host-restart card now says Orca restarted instead of the generic not-sent line.

* fix(native-chat): fit the queue to main's typed rejections and compaction result

Main (#23026) dropped the disposition's fresh-id retry field, gives a
rejected dispatch a typed sentence plus fact, and types /compact's result.
The queued-draft disposition and the queue tests now use those shapes.

* fix(native-chat): a returned queued card's words leave out sending again

The card offers its own Send, so its caption is worded with the retry control present, as the delivery notices are.

* fix(native-chat): a queued send in doubt that survives a Stop waits for the user's Retry

The unconfirmed probe resent it onto the session the user had just stopped, starting a new turn when the host never got the first attempt. A Stop now parks it the way a recovered unknown is parked.

* fix(native-chat): a withdrawn send the host returns as a card is not also put back in the composer

When the withdrawn submission and the returned card arrived in one frame, the journal reconcile restored the text before the card retired the entry, so it showed twice.

* fix(native-chat): a send stops asking the host to queue it once the host no longer can

delivery was fixed at enqueue, so after a host rollback every Retry of a queued send was refused on the same strict field. It is now decided per attempt: an id already sent keeps it while the host can read it, an id never sent takes the current choice, and a host without the capability never sees it.

* fix(native-chat): draft bookkeeping can never roll back the journal row it rides

The queued-draft returned transition runs inside every journal append's
transaction. A throw there (a draft table an earlier build created without the
returned_rejection column) rolled back the journal's own rejection row, so a
Stop, a failed start or a provider refusal could not be recorded. The standing
hook now runs in its own savepoint: its failure is logged and rolls back alone,
and the open-time repair re-derives the missed transition from the committed
row. The draft table also gains any missing nullable column at open.

* fix(native-chat): a draft a Stop or restart took back waits again instead of blocking the queue

Cards A, B and C wait; the turn ends and the drain consumes A, but the agent
has not taken it yet. A Stop then pauses B and C and withdraws A's submission,
which made A a returned card. The user's next send lifted B and C, yet a
returned card blocks everything behind it, so B and C never sent although they
read "sends after your next message". A restart or close before hand-over did
the same.

Nobody failed the user there, so the draft now goes back to waiting at its own
position, under the hold that same event put on the drafts behind it: a Stop's
'stopped', or no stored hold after a restart, whose hold derives from the host
instance. It carries no refusal, and records its spent submission id in
consumed_as, so its next consume (the drain, or Send on the card) mints a fresh
id through the same path a returned card's re-send uses. Provider refusals and
other failures still return the card. The live settlement hook and the
open-time repair share one decision. After a Stop and the user's next turn,
A drains first, then B, then C, one per turn.

* fix(native-chat): Delete and Send on a queued card answer at once during a /compact

A /compact holds the chat's serialized lane for its whole provider call, and
the queued-card Delete and Send ran on that lane, so both hung until the
compaction finished. They now run on the side lane a draft-only send already
uses while a compaction is in flight: Delete completes at once, and Send
reaches its readable "wait for the conversation operation" refusal at once.
The drain stays on the main lane and keeps its command hold, so nothing sends
until the compaction settles.

* fix(native-chat): a re-sent returned card drops the refusal it came back with

Re-consuming a returned card left returned_reason and returned_rejection on the
now-dispatched row, so the row described a refusal that no longer applied. The
consume clears both in the same update that moves the card to dispatched.

* perf(native-chat): the queue gate reads pending prompts without rendering the journal

The prompt check ran on every send admission and drain step, and read
journal.snapshot(), which copies and sorts every item in the chat. It now walks
the reduced items in place with journal.visitItems; the answer is the same,
since the snapshot only sorts those items.

* fix(native-chat): a Stop that fails leaves the queued cards as it found them

Stop holds the waiting cards before it withdraws queued sends and interrupts
the agent. When a later step threw or the Stop was refused, the cards stayed
paused ("sends after your next message") although a failed Stop is meant to
change nothing. A failed Stop now undoes exactly what it added: each card it
held gets back the hold it replaced, a consumed card its withdrawal sent back
to waiting is released, and the user sends it had set aside can again lift the
pause. Holds an earlier Stop or a restart put on the cards stay.

The hold SQL moves to its own module, and the draft store's standalone
transactions share one helper.

* docs(native-chat): confirmed cancellation is no longer a queue rollout prerequisite

Stop withdrawing queued sends with a typed cancellation landed on main with
#23026. The comment gating the queued-messages capability now lists only what
remains: the Codex steer matrix (#21062), the Claude fold receipt, turn-owner
bars, and the desktop and phone clients.

* docs(native-chat): the Claude fold receipt and turn-owner bars have landed; Codex steer and the clients remain

* test(native-chat): type the returned-card restore test's hook props

* fix(native-chat): a draft a Stop put back stays visible as a card

The card list hid a waiting draft whose id already had a submission. A Stop that withdraws a consumed draft requeues it under the same id while the first submission stays rejected, so the draft vanished from both the cards and the transcript. Only a submission that was not rejected now hides its card.

* 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): an unanswered capability probe no longer rewrites a queued send

The per-attempt delivery decision was stored on the entry, so a replay during the window before the host's queued-messages probe answered, or after it failed, was saved without delivery; the host's ledger then refused every later replay of that id. The entry now keeps the user's intent, the wire field is decided per request, a queue send waits while the capability is unknown, and a failed probe is asked again when contact with a remote host is regained.

* 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): a queued send in flight when Stop lands is never resent by the probe

Stop parked only sends already unconfirmed; one still dispatching whose answer later came back unknown was left to the unconfirmed probe, which resent it onto the stopped session. The entry now records that a Stop outlived it, the probe skips it, and only the user's Retry, which clears the mark, sends it again. This replaces the retryAfterUnknownSubmittedAt parking for the unconfirmed case.

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

* refactor(native-chat): a queued send is matched to its hand-off by queuedMessageId, never by id

The host now hands every queued draft off under a fresh submission id and names the draft on the submission. The card list hides a waiting card only for a live hand-off linked to it; an outbox entry a submission links to belongs to the host in any state (one rule, in a queue-aware reconcile both readers use); a withdrawn hand-off is never restored to the composer; a send answered with the hand-off settles as held; and the Stop and in-flight checks read the same link. This replaces the rejected-submission filter and the published-id restore skip.

* test(native-chat): read the outbox only after the replayed send's answer lands

* fix(native-chat): an echo withdraws a draft only if its rejected hand-off reached the agent

The delivered-echo rule withdrew a waiting draft when a provider echo matched
any rejected hand-off of it, including one a Stop rejected before it was ever
handed over. That hand-off is provably unwritten, so a matching unclaimed echo
is some other message, and the rule silently deleted the card. Only a hand-off
that was handed over and then rejected as never delivered can be disproved by
an echo now.

* fix(native-chat): a skipped echo withdrawal is re-derived before the draft can send again

The delivered-echo withdrawal rides each journal append as bookkeeping, and a
skipped hook left the draft waiting, so it later sent the same message a
second time. Nothing re-derived it. The draft store now also withdraws, in its
owed-settlement pass, each waiting draft that an echo already in the journal
proves delivered: an unclaimed provider user message (still stored under its
own id), carrying the draft's payload, appended after a hand-off that was
handed over and rejected. The live hook and the re-derivation share one
predicate. The pass runs at open and in the drain step, right before a draft
would send; it reads every item, so it never runs per streamed row.

* fix(native-chat): a rolled-back journal append leaves no draft state cached

The draft store caches its row list by revision. The per-row hook read that
list eagerly inside the append's transaction, after the consume in the same
transaction had already written and bumped the revision, so a failed COMMIT
left the cache showing a hand-off that never happened. The hook now reads the
drafts only once a row holds an unclaimed echo, and any rollback of a journal
append or of its bookkeeping savepoint invalidates the cache, so no other read
inside the transaction can leave it stale either.

* fix(native-chat): a replay of a deleted queued card answers withdrawn, not refused

Once a deleted card's tombstone is pruned, a replay of the send that queued it
found the draft through its last hand-off. When that hand-off had been
rejected (the card came back, and the user then deleted it), the replay
answered with the rejected submission, which clients show as a failed send
with a Retry. Only a withdrawn row is pruned while its last hand-off stands
rejected, so the replay now answers queued, withdrawn.

* fix(native-chat): a queued send records what it sent instead of waiting on the capability

The round-two hold kept a queue send back while the host's capability was unknown, which hid its text, wedged every later send, made Retry a no-op and let Stop restore text the host held. There is no hold now: the entry records what its first attempt sent and every replay sends exactly that (dropping it only for a host known not to read it); a first attempt asks to be queued only of a host known to queue with the setting on, and otherwise goes out plain as before; the transcript hides only a send whose request asks to be queued; Stop keeps any queue send that has gone out and parks it, unconfirmed, for the user's Retry, which the drain and an owner change now respect too.

* test(native-chat): a replayed send of a deleted card is spent, with no restore and no Retry

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

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

* fix(native-chat): a Stop marks a queue send without rewriting its state; the entry stores only what it sent

Stop set an in-flight queue send to unconfirmed, so a settled refusal answering its first attempt kept the old id, and every Retry replayed into the same recorded refusal. Stop now only marks the entry, and the drain never admits a marked queued entry, so its state and refusal notice stay what its answer made them. The stored delivery intent is gone: an entry keeps only sentDelivery, recorded by its first attempt; a never-attempted send decides at attempt time.

* docs(native-chat): no send waits on an unknown queued-messages capability

* test(native-chat): one import of the outbox module in the owner-change test

* test(native-chat): read a stale outbox entry without a JSON round-trip

* fix(native-chat): keep a first attempt's recorded delivery a literal

* fix(native-chat): an interrupted send attempted before a Stop reads as unconfirmed, never as not sent

* 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(native-chat): a paused queue shows one header row with Resume; cards keep Steer

The host now publishes the queue's pause once (queuePause: stopped, restarted or cleared) beside the list, and per-card holds mean only a failed send. The card list shows a header row above the cards naming why the queue is paused, with a Resume button that calls agentSession.queuedMessagesResume (a failure is the usual toast). Cards keep Steer, Delete and More actions while the queue is paused; the old per-card paused caption is gone. Steer's tooltip now reads Submit without interrupting the model.

* test(native-chat): the coalescer keeps the pause of the latest list

* test(native-chat): fit the queue tests to the queue-level pause types

* fix(native-chat): a queue pause covers only the cards it paused

A Stop recorded its pause fact even when the queue had no cards, and the fact
outlived the cards it did pause. The published list hid a pause over no cards,
but the drain still treated the queue as paused, so a card typed much later —
during an orchestration-mail turn, or a correction typed before the stopped
turn ended — sat under "paused because you interrupted" with no Stop of its
own.

A Stop now records its pause only if the queue holds a card when the Stop takes
effect (the hand-offs its withdrawal sent back included). The fact is retired
in the same transaction as the Delete, consume or withdrawal that empties the
queue, never from an async publish. A /clear's carry now lands each card with
its 'cleared' pause in one transaction, so a failed insert leaves no pause over
an empty replacement.

* perf(native-chat): the queue's pause reads the latest person's turn in O(1)

The pause is derived on every publish, per subscriber, and each derivation
copied and scanned every submission to find a person's accepted turn after the
Stop. The reducer now keeps that fact as it folds rows: the submission row of
the latest accepted turn whose origin is `client`. The Stop's and the
restart's lift both read it directly.

* fix(native-chat): each Resume press is its own operation, and a pause-only frame updates state

Resume names no target, so reusing its operation id after a failed press replayed a stale answer or the same refusal. The reducer's no-change check also ignored the queue pause, dropping a frame that changed only the pause.

* 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): the paused-queue row shows only over cards Resume can send, and matches the queue's icons

The header row appears only when a card waits on nothing but the queue's pause; Resume shows it is pending, hands focus back to the composer like the card actions, and the truncated line keeps its full text as a title. A paused queue outranks a pending prompt in the card's hold, as on the phone. Steer carries the corner-down-right arrow, a queued card leads with the list-end glyph, and a card whose send failed leads with the alert, as a returned one does.

* test(native-chat): Resume reports itself in flight until it settles

* fix(native-chat): a queue pause shows only while Resume would send something

After a Stop whose only remaining card was a returned one, or after a restart
with only a card held by its own failed send, the queue published a pause with
a Resume that could send nothing: a returned card waits for the user anyway,
and a held one for its own Send. The pause is now published, recorded by a
Stop, and kept only over a card it can hold back — waiting, with no hold of
its own. The fact is retired in the same transaction as the write that removes
the last such card, a hold or a refusal included.

The publication's dedup also compared only the pause's reason, so a pause
appearing or clearing with no readable reason could read as unchanged; it now
compares presence first.

* fix(native-chat): a queue pause counts only cards Resume would actually send

A waiting card behind a returned one is blocked until the user acts on the
returned card — the drain never sends past it — so a pause over only such
cards still offered a Resume that sent nothing. The rule for "a card Resume
would send" is now one function: waiting, no hold of its own, and not behind a
returned card. The publication, a Stop's record and the fact's retirement all
read it; retirement reads the rows in position order inside the same
transaction as the write that took the last such card.

* fix(native-chat): a returned card that blocks the paused cards hides the pause but keeps it

The last change retired a Stop's pause as soon as a returned card blocked every
paused card. Deleting that returned card then sent the cards behind it at once,
with no Resume — not what the user asked for.

The two rules are now separate. The pause is KEPT (recorded by a Stop, retired
in the same transaction as the write that takes the last one) while any waiting
card with no hold of its own exists, wherever it sits. It is PUBLISHED only
while such a card is not behind a returned one, so the header never offers a
Resume that sends nothing. Deleting the blocking card shows the pause again,
and the cards behind it wait for Resume or the user's next turn.

* test(native-chat): build the pause-only batch through the typed helper

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

* fix(native-chat): the queued area is one bordered box, and the Steer tooltip spaces its shortcut

The pause row, when shown, is the box's first row and each card a row below it, divided rather than individually bordered. The Steer tooltip groups its hint and the shortcut chips with the house gap, so the chips no longer touch the text.

* 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(native-chat): a queued card Steered into a turn joins it under the opener's bar

A Steer hands the draft over under a fresh submission id, linked by queuedMessageId, and the host scopes its row to the running turn; the transcript keeps it inside that turn with no bar of its own, settled or running.

* fix(native-chat): queued messages sit above running shells and agents, which stay next to the composer

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-29 19:12:26 -07:00