Skip to content

[upstream #24330] fix(web): drop the macOS traffic-light inset from the paired web client - #287

Open
innocarpe wants to merge 5264 commits into
mainfrom
fix-19299-web-traffic-light-pad
Open

innocarpe wants to merge 5264 commits into
mainfrom
fix-19299-web-traffic-light-pad

Conversation

@innocarpe

Copy link
Copy Markdown
Owner

Portfolio mirror of my contribution to upstream stablyai/orca.
Exhibition only — the real review/merge target is upstream.

Upstream

Summary

ELI5 Opening Orca in a browser on a Mac left an empty gap at the top left, where the desktop app's red, yellow, and green window buttons would be. The browser has no such buttons, so that gap is gone. The desktop Mac app still leaves room for them. ## What Changed Before, a

Note

  • Do not merge this into innocarpe/orca main until the upstream PR is merged.
  • After upstream merges: sync fork from upstream, then close this mirror PR.
  • This open PR exists so visitors see in-flight work on this fork's Pull requests tab.

Jinwoo-H and others added 30 commits September 28, 2026 15:49
…on (stablyai#23597)

* fix(terminal): let unselected Cmd+C reach apps that negotiated kitty keyboard

On macOS Orca swallowed an unselected Cmd+C as a no-op copy, so a full-screen
TUI like Codex, which captures the mouse and keeps its highlight out of xterm's
selection, never received its own copy chord.

- isAppOwnedCopyChord (xterm-bypass-policy) is the one rule: macOS, no xterm
  selection, and non-zero kitty flags from the pane's mirror. The pane's xterm
  bypass and the dashboard popout's key handler both use it.
- A selection copy stays claimed through its repeats and release, including
  custom copy bindings, so kitty event reporting cannot leak them to the PTY.
- Plain shells keep sending nothing; Linux and Windows are unchanged.
- The e2e kitty helpers move to helpers/terminal-kitty-keyboard.ts so the
  shortcut spec stays under the line limit.

* fix(terminal): popout copy ownership reads xterm's selection like the pane

A highlight of blank cells trims to empty text but is still a selection, so the
popout must not hand that Cmd+C to a kitty app while the pane withholds it.

* test(terminal): keep the held-copy binding test beside the copy dispatch tests

The shortcut-policy suite is at its line limit.

* test(terminal): stub a blank-cell selection without widening the preview harness type

* style(terminal): tighten the app-owned copy comment and read the popout selection after the early return
…n the host and pane (stablyai#23602)

* test(native-chat): await the async history and journal snapshot in three tests (#23560)

#22835 made history() and journalSnapshot() async; tests from #23502 and #22944 still call them synchronously, so the typecheck job is red on every PR while main pushes do not run it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(usage): show ZCode Coding Plan quota on current main (#23520)

Shows the ZCode Coding Plan quota in the status bar alongside the Claude and Codex usage readouts, reading the key from the user's own ZCode config.

Credentials are scoped tightly: the host must be an exact match in the allowlist, HTTPS on port 443 only, `redirect: 'error'`, and the key is checked for CR/LF before it reaches a header. The key itself is never stored or logged — account identity is an HMAC.

Both JSON inputs (a user-edited config file and the remote quota response) are narrowed at runtime rather than asserted, and the request cancels an unread response body on the error path so it cannot trip the undici parser crash (orca#8695).

Co-authored-by: guanbear <guanbear@users.noreply.github.com>

* fix(mobile): paired clients re-derive a kept terminal after a cold restore (#23109)

* fix(mobile): paired clients re-derive a kept terminal after a cold restore

A renderer frame published before a cold-restored terminal's PTY registered
was fenced to an empty tab list and recorded as accepted, and the renderer
never resends unchanged content. When registerPty binds a surface the
accepted frame fenced out, re-merge that frame so the fence reads current
state.

* test(mobile): drive the live desktop window through the runtime's desktop seam

* test(mobile): the re-derive path never flushes the store synchronously

* test(mobile): a re-derived frame must not bring back a surface the host retired after accept

* fix(mobile): a re-derived frame changes membership only for the registering surface

The replay re-ran the whole accepted frame, so a surface the host retired
after accept (a phone close whose remote PTY is still exiting, or a closed
chat tab) came back. Every other surface now keeps the host's current
decision; the removal repair is extracted from the terminal retirement
helper so non-terminal tabs are removed the same way.

* test(mobile): a re-derived frame must not drop or disown a phone-created terminal the desktop has not published

* fix(mobile): a re-derived frame does not infer renderer retirements from its older frame

* revert(mobile): drop the replay of a fenced renderer frame

Reverts the production parts of a88e1eaa0a, f5b99d0003 and 5af1c6d97a: the kept
renderer frame, rederiveFencedRendererSurface and its registerPty call, and the
mergeRendererMobileSnapshot / removeMobileSessionSnapshotTabs extractions. The
fence will instead read the host's saved membership record. The test file stays
and is rewritten for that mechanism.

* fix(mobile): the paired-list fence admits a terminal the saved session still lists

After a cold restore the in-memory mobile snapshot and PTY table start empty,
so in a repo with host-authoritative terminal membership the fence dropped a
restored terminal whose renderer frame arrived before its PTY registered, and
paired clients never listed it. The fence now also admits a surface the host's
saved workspace session still lists (tab under the worktree, leaf in its
layout), and registerPty pushes the listing so pending-handle turns ready at
once. A restored pane whose PTY never returns is listed as pending-handle, as in
repos that are not host-authoritative.

* test(mobile): keep the desktop window stub's type assertion on its SAFETY line

* fix(mobile): coalesce the registration push for a listed restored terminal

registerPty pushed the paired list immediately on every registration that backs a
listed surface. The desktop's graph sync after a spawn already publishes the same
pending-handle to ready flip on the 50 ms coalescing window, so each restored pane
cost two pushes, and a restore of N panes cost N immediate full-list pushes per
client. The touch now rides the same coalescing window, which still covers a
registration no graph change follows.

The test's "unchanged" sync dropped the graph's tab, which is itself a change, and
its no-extra-push assertion ran before any coalesced push could fire; both are
fixed, and a restore of two panes is asserted to push once.

The fence comment no longer claims the new clause keeps pending leaves out of the
graph: once the surface is listed, its leaves pass the shared predicate through
that listing, as any listed surface's do.

* fix(mobile): read saved membership only from the worktree's own session partition

For a runtime-host workspace, emptying the owning partition re-routes session reads to a single
other partition that still lists the worktree. If that older copy lists a surface a retirement just
removed, a lagging renderer frame could re-admit it. The saved-membership check now reads only the
partition the worktree's host names, so it never trusts a fallback copy.

* test(mobile): pin the own saved partition for every workspace host kind

Also correct the immediate-emit comment: only an exit bypasses the window; a registration's ready flip coalesces.

* Fix terminal focus when Cmd+J wakes a workspace (#23546)

* fix: retain workspace terminal focus through wake restoration

* test: reset CPU throttling after wake focus assertion

* fix: require terminal textarea readiness before claiming focus

* test: configure React act environment for dialog regression

* fix(mobile): size a terminal's first subscribe from the document's reported cell box (#23080)

* fix(mobile): size a terminal's first subscribe from the document's reported cell box

#22960 sent phone dims on a terminal's first subscribe by opening a throwaway
empty terminal (init 80x24 ""), awaiting its ready and measuring, behind a
per-document first-subscribe mark whose lifetime was tied to web-ready. That
cost a second xterm/WebGL instance and ~150 ms per open, plus lifecycle state.

The document now measures the cell box without a terminal (xterm 6's
CharSizeService strategy, rounded as the renderer rounds it) for every
text-size preset and reports it with its viewport in web-ready; a table,
because the text scale only reaches the document after that notify. Each
init's ready reports the box xterm actually laid out, which replaces the
probe's entry. The controller answers fitDimensions/measureFitDimensions from
that table and the view's layout with no message; without a table it asks the
document as before.

The session seeds an unmeasured viewport synchronously in subscribeToTerminal,
so the first subscribe carries dims by construction. Deleted: the empty init,
its awaitReady gate, deferFirstSubscribeUntilViewportMeasured and the
subscribedDocuments mark. The fit pass is unchanged and still covers a
document that reports no cell box.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): read the reported cell box through in-narrowing, not Reflect.get

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): correct the probe's cell-box guess from the box xterm lays out

The web-ready probe is a guess: building the WebGL addon creates no context,
so a context that fails on load lands on the DOM renderer, whose width is not
snapped and depends on the column count. Before, a ready box that differed was
only logged; the first subscribe had carried the wrong column count, the host
echoed it, the fit pass saw the viewport equal to the host's dims, and the grid
stayed slightly shrunk. The store also kept the WebGL width after a context loss.

The document now reports the box xterm laid out whenever it changes (from
onRender, which covers a renderer swap and a DPR change that
onDimensionsChange does not fire for, and at ready). The store replaces the
guess; when that changes the current text size's entry, the view calls
onCellBoxChange with xterm's grid and the session re-fits, running the bounded
fit pass if the dims moved (one resubscribe). Equal boxes do nothing.

The RN layout box now survives a document reload; the document's own viewport
only stands in until the view reports a layout (on the page, web-ready arrives
first). The mismatch console.log is gone, and the probe's rounding names the
xterm version it copies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): correct each cell-box guess at most once, so a DOM renderer cannot loop

On the DOM renderer the cell width is the rounded canvas width divided by the
column count, so every re-init at new cols reported a new box. Each one counted
as a correction, a floor over floats could flip the fit between two sizes, and
each flip landed converged, which reset the resubscribe budget: an unbounded
series of full-snapshot resubscribes.

Only the first laid-out box for a guessed text size may be a correction; later
reports still update the store, so fits stay truthful, but never resubscribe on
their own. The fit's floor gains a 1e-6 epsilon so floating-point error at an
exact boundary cannot flip a column or row. New document tests pin the render
report after a renderer swap and the report at ready for a paused renderer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): make xterm the only terminal cell measurer

The document builds its real terminal before web-ready, at the app's
text scale, and reports the box xterm laid out; the first init reuses
that terminal. The page-side prediction, the per-scale guess table and
the once-per-document correction are gone. The app remembers the box
per text scale for its lifetime, so a later open at a known scale
subscribes with phone dims at once. A box that changes at the same grid
(renderer swap, pixel ratio) refits the open terminal in place.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep commands queued before the terminal WebView first loads

A subscribe sized from the stored cell box can queue init before the
native WebView reports its first load start, which cleared the queue
and left the terminal blank. Only a reload now drops queued commands.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): re-init a document that lacks the subscription's init, and fit one frame width

- Web-ready now says whether the document holds the terminal's latest init
  (a reload before the first ready drops a queued one); the session
  resubscribes any initialized terminal whose document lacks it.
- One grid fit, shared by the app and the document, fed the unrounded frame
  width React Native laid out; it keeps exact fits whole at fractional pixel
  ratios. The document's viewport-width fits are gone.
- The page builds every document at the scale the view mounted with, as the
  native WebView does.
- A new document's first cell box is compared against the grid the
  subscribe fitted from the stored box.
- The terminal built before ready stays hidden until its first init.
- The cell-box census matches glyph-measurement techniques, not names;
  the store's unused clear() is gone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): build the terminal before ready only for the view shown at mount

A session mounts one terminal view per tab, and each built xterm and a
WebGL context before ready: 20 tabs made 20 contexts at load, past the
~16 a page (or Android's shared WebView renderer) holds, and native logged
32 context losses. Only the view shown when it mounts builds early now;
the rest build at their first init as before. Deferring the WebGL addon
instead would change the reported box: the DOM renderer lays out 7.8x15
where WebGL lays out 7.667x15 at the same font.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): write a WebView document's start values into its page, not an injected script

Android ran the pre-content injected script after the document's own in
1 of 22 documents on the emulator; that document started with no text
scale or shown flag and built a terminal it should not have. The values
now sit in the page ahead of the document script, one source object per
start pair so a render never reloads the WebView.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the pre-ready terminal measures and reports while hidden

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): measure only the laid-out frame, and refit on a new grid, not a new width

- A measure needs both of the frame's dimensions from React Native; the
  document's viewport-height fallback is gone, and before the first layout
  the handle answers no fit without asking the document.
- A frame width change that still fits the PTY's grid from the stored box is
  a no-op, so sub-pixel layout jitter no longer re-measures. The width ref is
  written in that effect rather than during render (react-doctor).
- One "last grid" ref: the last reported grid, or the one a subscribe fitted
  from the stored box.
- The page render rig measures through the frame it laid out, as the session
  does, and lets the replay's fit settle before its resize-refit witness.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let only the current terminal document's ready flush

A reload kept the WebView and its onMessage, so the old document's late
web-ready flushed the queue into the reloading view and the new document
got a second init. Each document now gets its own view (keyed on a
generation the controller owns), every notify carries the generation of
the view that received it, and a web-ready from a replaced document
flushes nothing and stamps nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop every notify from a replaced terminal document

One rule at the receive boundary: a notify from any generation but the
current one is dropped, whatever its type, not only web-ready.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make fitDimensions a pure question; name each generation counter

- fitDimensions no longer records the grid. A width change to a new grid
  asked it first, so the DOM renderer's report of that grid's box read as
  "same grid, new box" and refit again. Only the first-subscribe seed
  (seedFitDimensions) records the grid the document's first report is
  checked against.
- viewGeneration counts the views, readyGeneration counts web-readies.
- replaceDocument no longer resets the load flag; the load-start reset
  stays as the guard for a view that reloads itself.
- The name-based lifecycle census is replaced by a behavioural test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): typecheck the handle mocks, drop the unused cell-box get

- The two handle mocks carry both fitDimensions and seedFitDimensions,
  and the fake-timer acts return nothing, so the three test files check
  under tsconfig.test.json again.
- terminalCellBoxes.get had no product caller; the store's tests assert
  through fit.
- The load-start comment says what the controller does now.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the grid the document has, ignore a replaced view's load start, dispose a failed pre-ready terminal

- The document reports a new grid even with an unchanged box, so an
  in-place reflow on WebGL is held before a later renderer swap at that
  grid; the swap then refits. The app's apply paths do not hold the grid
  themselves: the DOM renderer's box follows cols, and a grid held on
  apply would read its own box as a renderer change and loop. One
  writer (holdGrid) holds the seeded or reported grid.
- A load start from a view a replacement unmounted is ignored, as its
  notifies already are.
- A terminal whose open throws before ready is disposed, not only
  unreferenced.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): ignore every native event from a replaced terminal view

One wrapper binds each WebView lifecycle event (load start, error, HTTP
error, render process gone, content process terminated) to the view's
generation, so a replaced view's late event cannot reset, replace or
put an error over the current document.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): a DOM seed refits once on its first report, not on the refit's own

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): subscribe a terminal only after its document is ready

The document still builds its terminal before ready and reports the cell
box xterm laid out in web-ready; the app now subscribes after that ready
and fits from that box, so nothing is sent to a document before it is
ready. Everything that made a pre-ready subscribe safe goes: the
app-lifetime box store, the seed fit, the per-document view generations
and their event filtering, the init tracker and the hasInit resubscribe.
The native view reloads in place again and web-ready keeps main's reload
rule. Boxes are kept per view; the grid a document last reported still
guards the in-place refit against the DOM renderer's cols-dependent box.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): hold one reported cell box and the grid the subscribe fitted

The controller keeps only the box the current document last reported,
not a per-text-size store: the document re-reports on a scale change.
The subscribe after ready fits from that box and holds the grid it
fitted, so the DOM renderer's first report at that grid (a new box)
refits once in place and converges; refit and apply paths hold nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): fit only a ready box at the app's scale; forget a reloaded document's box and grid

A reload keeps the document's mount scale, so a ready after a text-size
change reports a box at the old scale; that box no longer sizes the first
subscribe, which then takes the no-box path. A readiness reset drops the
old document's box and held grid, so the new document's first DOM report
at the same grid does not refit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give the terminal document its frame at init, and fit text scale over it only

A subscribe sized from the ready box sends no measure, so the document
had no frame when the text size changed and reported the pre-refit row
pitch. The app's init now carries the frame it laid out, in the fields a
measure uses; the router takes it from either. The text-scale fit reads
only that frame, with no viewport fallback.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why a frameless text-scale change skips the resize

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one cell box per terminal notify, not an array

web-ready and cell-metrics carry `cellBox: {fontScale, cellWidth,
cellHeight} | null`; the document's `laidOutCellBox` returns one or
null and the parser validates one object. The text-scale match moves
from web-ready into `handle.fitDimensions`, the one place a box is fitted.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): fit terminals in the app from the reported box; drop the measure round trip

The app already holds the box the document reported, so the refit and
the fit pass await the init's ready and call `handle.fitDimensions`
instead of posting `measure` and waiting on `measure-result`. The
document's measure, its retries, and the measure promise and timeout go.
The document still resizes locally on a text-size change, so every grid
the app sends (init, resize, reflow) carries the laid-out frame it was
fitted to. `holdSubscribedGrid` replaces `subscribeFitDimensions`, so
the only fits are `fitDimensionsFromCell` and `handle.fitDimensions`.
The render rig reads its fit from the ready box. The recorder adapter
mounts the new handle with the same recorded effects; the goldens it
mounts move on their adapterSha256 header only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): keep the terminal frame in one ref, and notify a new width imperatively

The session held the frame in a height ref, a width ref, a width state
and the refit's own width ref. It now holds one `terminalFrameRef`
({width, height} | null until the first layout; a hidden 0x0 layout
keeps the last box). onLayout notifies a new width imperatively, as it
does height, and the refit's notify skips a width whose fit is the grid
the PTY has. `terminal-frame-width-refit.ts`, the width state and its
effect go. The subscribe's layout gate reads "no frame yet" directly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): subscribe a held-back terminal on the frame's first layout only

`handleTerminalFrameLayout` ran on every onLayout; it now runs once, when
the frame first has a size. Later layouts only notify a new width.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): size the first subscribe inline in subscribeToTerminal

`sizeTerminalViewportFromCellBox` wrapped five lines in a 37-line
module; the subscribe now fits the ready box against the frame, holds
that grid and records the diagnostic itself. The helper's tests fold
into the subscription tests, which move to the subscription's name.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the unreachable font-size guard on the reported cell box

xterm 6.1.0-beta.303 updates the render service's cell box in the same
task that sets `options.fontSize`: CharSizeService.measure fires
onCharSizeChange, and RenderService.handleCharSizeChanged runs the
renderer's `_updateDimensions` (DomRenderer.ts:359, WebglRenderer.ts:229).
`term.onRender` fires from RenderService._renderRows after the rows
are drawn (RenderService.ts:213, CoreBrowserTerminal.ts:538), and the
document writes its text scale and the font size in one task
(text-scaling.ts applyTextScale, terminal-init.ts init). So no report
can read a box between the font and the scale; the guard and its test
go. A new test pins the real order: no report when the font is set,
the new box at the new scale on the next render.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one start seam, no source cache, the reported box as an object

- `useState` already pins each view's WebView source at mount (a new
  test re-renders at another text scale and gets the same object), so
  the module-level `webViewSources` Map goes.
- `initialTextScale` and `buildsTerminalBeforeReady` become one
  `start(): { textScale, shown }` seam.
- `reportedCellBox` holds the last reported box and grid, not a string key.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to this branch and re-record

The terminal refit now fits in the app from the reported box and reads
one frame ref, so the recorder's terminal adapter mounts the new handle
(`awaitReady` + `fitDimensions`) and options (`terminalFrameRef`),
keeping its recorded effects. `baseline` is repinned to 21954dbd2f1, the
last commit to touch a fenced path, and every golden is re-recorded.
Proof by class against HEAD: 787 header-only, 0 body moved, 0 added,
0 deleted. Header keys moved: `baseline` on all 787, and `adapterSha256`
on the 14 goldens `terminal-mount-adapters.ts` mounts (query-reply 3,
accessory-raw-send 4, takeover-report 4, viewport-refit 3). No recorded
traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): hold the reported cell box and its grid in one ref

The controller kept the box in `cellBoxRef`, the grid in a string
`lastGridRef` and wrote it through `terminal-held-grid.ts`. One
`heldRef` now holds `{ cellBox, grid }`, as the document's own
`reportedCellBox` does: web-ready writes the box, every cell-metrics
report writes both, `holdSubscribedGrid` writes the grid, and a
readiness reset clears it. Same write points, so the one-refit bound
holds; the DOM-loop and refit-once tests pass unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the hold-rule commit and re-record

H (f00bebba483) touched a fenced path after the last repin, so
`baseline` moves to it and every golden is re-recorded. Against the
corpus before this branch's refreshes (21954dbd2f1): 787 header-only,
0 body moved, 0 added, 0 deleted; `baseline` on all 787 and
`adapterSha256` on the 14 goldens `terminal-mount-adapters.ts` mounts.
Against the previous refresh: `baseline` only. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the main merge and re-record

The merge (b3b1b0def22) is the last commit to touch a fenced path, so
`baseline` moves to it and every golden is re-recorded. Against
97b5bb2b9ac: 787 header-only, 0 body moved, 0 added, 0 deleted;
`baseline` on all 787, and `adapterSha256` on the 14
session.diff-review-actions goldens whose adapter #22951 edited. Against
origin/main: 787 header-only, 0 body moved/added/deleted; `baseline` on
all 787 and `adapterSha256` on this branch's 14 terminal goldens.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): the terminal document holds the grid and decides each refit

The document already kept the last reported box and grid; the app kept
a mirror of both to decide the refit. Now the document decides: its
`cell-box` notify carries `{ cellBox, refit }`, sent only when the box
changes, with `refit` a box that changed at a kept grid. web-ready
records the pre-ready terminal's box at its 80x24 grid, and the first
init that reuses that terminal holds the init's grid, so the DOM
renderer's first report refits once, as the subscribe's hold did. A
re-init no longer clears the record, so a new renderer at the same grid
still refits. The app keeps one `cellBoxRef` and `holdSubscribedGrid`,
`heldRef` and the grid on the notify go. The one-refit, DOM-loop and
renderer-swap tests move to the document with the same scenarios.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one init options object, and a frame on every grid

`init` takes `{ cols, rows, data, preserveScroll, oscLinks, frame }`
instead of six positionals, and `init`, `resize` and `reflow` (handle
and messages) require `frame: TerminalFrame | null`. The refit's reflow
check reads `!dims` alone, and the controller's test file is named for
the `cell-box` notify it now covers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): one notifyTerminalFrame for the frame's layout

The frame's onLayout made four calls and held the classification
itself. It now calls `notifyTerminalFrame({ width, height })`, and the
session's terminal-webview hook keeps the one frame ref, notifies the
height, subscribes the document held back for the first layout, and
notifies a later width change. `handleTerminalFrameLayout` is named for
what it does: `subscribeIntendedActiveTerminal`. The layout tests move
to that hook.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the round-8 head and re-record

85d421963c6 is the last commit to touch a fenced path. Against
a676c1b65a7: 787 header-only, 0 body moved/added/deleted, `baseline`
only. Against origin/main: 787 header-only, 0 body moved/added/deleted;
`baseline` on all 787 and `adapterSha256` on this branch's 14 terminal
goldens. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): name the init option initialData, as the message does

The init option `data` becomes `initialData`, the message field's name,
so the controller passes it through unrenamed. The `preserveScroll` why
stays on the message type only, and the document test's title names the
three grids that carry the frame.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the RPC recordings to the round-9 head and re-record

486566c82b4 is the last commit to touch a fenced path. Against
3371c397150: 787 header-only, 0 body moved/added/deleted, `baseline`
only. Against origin/main: 787 header-only, 0 body moved/added/deleted;
`baseline` on all 787 and `adapterSha256` on this branch's 14 terminal
goldens. No recorded traffic moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci: rerun checks against main with #23560 landed

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(ai-vault): show ZCode CLI session history (#23513)

Surfaces ZCode CLI session history in AI Vault, so past ZCode sessions show up next to the other agents' instead of being invisible.

ZCode stores sessions in the same SQLite shape OpenCode uses, so this reuses the existing OpenCode lister and parser rather than adding a second scanner — the worker only varies the agent it stamps on each row. Discovery covers the native home and any WSL homes.

SQLite rows are narrowed at runtime rather than asserted: the statement API returns untyped column values, so the declared row shape is only a claim until something checks it, and a drifted schema or a database written by another tool reaches the same code.

Co-authored-by: guanbear <guanbear@users.noreply.github.com>

* test(mobile): repin the RPC recording corpus to main after #23080 (#23565)

#23080 squash-merged a corpus pinned to its branch commit 486566c82b4, which
the squash left unreachable from main. Repin baseline to main's tip and
re-record; every golden moves only its baseline header.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(ci): diff against the merge commit's first parent so PR checkouts can be shallow (#23562)

Every changed-path gate asked git for `--merge-base "$BASE_SHA" "$HEAD_SHA"`,
which needs the event payload's base SHA to be in the local graph. That is the
only reason two jobs cloned all 8127 refs' history. On a pull_request checkout
HEAD is already the merge commit, so its first parent is the base side and no
merge base has to be computed. config/scripts/git-pull-request-diff-base.mjs
resolved that for the two Node gates; the workflow's inline gates now use the
same helper through a small CLI rather than open-coding it.

code_paths gates all 22 jobs, so its checkout is charged to the start of every
one of them: measured 20.7s to 1.6s, keeping blob:none because its sparse tree
is ~7 files and leaves no blobs to refetch. Static analysis drops the filter
instead, since populating all 30,226 files makes blob:none force a second
promisor fetch: 23s to ~11s.

Verified on a real merge ref. At depth 50 the old and new forms produce
identical changed-file sets. At depth 2 the new form still works and the old one
fails with `fatal: bad object`, which is the failure a stale base would have
caused once the checkout stopped being complete.

Also drops the dead resolveBase + merge-base prelude in the changed-code gate,
whose result resolvePullRequestDiffBase already discarded on every PR.

* feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)

The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched.

* fix(agent-session): wait for in-flight session-store writes before teardown returns (#23545)

* fix(agent-session): stop lease renewal before the renewal's write lands

Clearing the renewal interval only cancelled the next tick. A tick already
past its guard still had a whole-file store transaction to commit, and the
store's transaction lock re-creates the store directory before it writes, so
that commit could land after host teardown had finished releasing everything
it touches.

`stop()` now resolves once the tick in flight has finished writing, and host
teardown's stop-lease-renewal phase waits for it. The three test harnesses
that model a host vanishing without a clean quit shared a copy of the same
incomplete shutdown; they now share one helper that waits.

The symptom was a CI flake: the refusal-oracle spec removes its temp
directory in `afterEach`, and a renewal landing mid-removal put the store
directory back, so the removal failed with ENOTEMPTY on the temp root.

* fix(agent-session): wait for the delivery loop's restart when abandoning a host

The abandon helper disposed the delivery loop and moved on. Disposing only
stops the loop's NEXT step: a step already past that check keeps going, and
the restart it runs for an accepted send reserves an owner, which is a store
commit. The store re-creates its own directory before every commit, so that
commit put the directory back under the temp-directory removal the test does
next, and the removal failed with ENOTEMPTY.

Quit already waits for exactly this work, in its drain-attaches phase — every
attach is registered with the task queue from enqueue. The helper now runs
the same drain, in quit's order, so it waits for both producers that reach
the store after the last awaited call returns.

Adds a regression test that holds the loop's restart inside its provider
acquisition and asserts abandoning does not return until it lands.

* chore: re-trigger PR checks

The push to 2ecc9c6e emitted no pull_request event, so the matrix never ran.

* fix(sidebar): clip worktree card content to its border (#23566)

* fix(runtime): answer terminal.subscribe at once for a pane the desktop already has mounted (#23512)

* fix(runtime): answer terminal.subscribe at once for a pane the desktop already has mounted

A mobile subscribe to a PTY with no headless model asked the renderer to
mount its tab and waited for a newer serializer settle. The renderer drops
mount requests for tabs it already has mounted, so a reattached daemon PTY
whose restored provider snapshot outranked the live renderer held the reply
for the full 3 s deadline. A live renderer screen now proves attachment and
is adopted directly; an unmounted pane still requests the mount and waits.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): treat any renderer answer, even a blank screen, as an attached pane

A fresh shell that has printed nothing has a registered serializer and an
empty screen; requiring non-empty data sent it back through the dropped
mount request and the 3 s wait. A blank screen skips the wait but does not
replace the chosen snapshot, so a parked pane cannot erase provider history.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): treat a mounted pane with unsettled output as attached

The stable renderer snapshot returned null both when no renderer answered
and when output advanced under every retry, so a desktop pane printing
continuously still took the dropped mount request and the 3 s wait. It now
returns a typed outcome (settled, moving, absent); moving skips the mount
and publishes the chosen snapshot, and late recovery still requires settled.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): adopt only a renderer-ordered screen when probing a mounted pane

The attachment probe read the terminal before knowing it would adopt, which
can reach the provider snapshot on the unmounted path; the read now follows
the decision. A seq-less renderer screen would replay every buffered chunk
on top of itself, so the probe keeps the chosen snapshot for it. The probe,
adopt and mount wait move into their own module to stay under the line cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): adopt a seq-less renderer screen when no output is pending

The seq gate only prevents a double replay of buffered output, so a settled
non-blank screen without a seq is safe when nothing is pending. That keeps
the better screen for a pane right after a deferred cold restore, before it
is renderer-ordered. The rule now applies after the mount wait as well.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): decide renderer attachment from the host's serializer flag

The mounted-pane probe re-derived attachment by serializing the renderer up to six
times, which cost ~7.5 s for a registered but unresponsive renderer on a busy PTY.
The host already holds that fact in the serializer readiness flag. The flag is never
cleared when a pane closes over a live PTY, so one null serializer answer falls back
to the mount wait: worst case is the old 3 s plus one 750 ms serialize.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(runtime): let the serializer's answer alone prove renderer attachment

serializeRendererTerminalBuffer already answers null when the host's serializer flag is
unset, so the separate flag accessor was redundant. The numeric-seq adopt test now
replays only the byte past the seam.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* Revert "perf(ci): shard the anti-slop audit across processes instead of one JS runtime (#23543)" (#23575)

This reverts commit fae0ae7a464c2991bd25885e7e61689c7f471a0f.

* perf(ci): cache pnpm verification records on Linux (#23568)

* perf(ci): pilot pnpm verification record caching on Linux

* test(ci): review pnpm verification record in mobile cache audit

* fix(native-chat): the host writes chat failures for a person, with a typed fact beside them (#23116)

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

* feat(native-chat): a typed failure fact beside every failure sentence

Adds the shared vocabulary the host writes a failure with: a closed failure kind, a
provider diagnostic that says who it is for (a person, or a log), and a refusal cause
beside the refusal code. Status rows gain an optional failure fact and rejected
submissions an optional rejection fact; the dispatch row carries it, the reducer reads
it field by field, and the projection forwards it. Older rows and older readers are
untouched: every field is optional and the schemas stay open.

* fix(native-chat): durable failure rows and rejection reasons are written for a person

Every host writer that records a failure now writes a sentence for a person beside a
typed fact, instead of embedding a refusal's message, an exception or a composed exit
string. A provider's own words travel as a separate diagnostic from the places Orca
composes them - the Claude and Codex exit stderr (a log), Codex's JSON-RPC message,
Claude's compact_error and Codex's turn error (for a person) - and are never inferred
from a string afterwards. Not signed in and oversized history are typed at the
adapter that detects them.

Covers start and restart failures, the delivery loop, dispatch rejections (content,
queue-full, write failures, provider refusals), cancel and answer confirmation rows,
compaction, the rewind fallback, and not_delivered, which released clients printed
as it was. Two leaks close on the way: a settlement retry no longer writes Orca's
probe evidence into the exit row, and an attach or journal-sink failure is recorded
as Orca's fault rather than as the provider stopping. The legacy rejection markers
and the reasons on sends in doubt stay byte-identical.

* feat(native-chat): refusals name their cause, and a failed start is worded in one place

A refusal now carries an optional cause beside its code: one closed enum of the situations
a chat write can meet, set at every emitter a structured-chat write reaches. Returned
refusals build it with refuse(code, cause, message). Store and host paths that raised a
bare Error(code) now throw AgentSessionRefusalError, whose message is still the code and
which has no code property; the RPC error mapper handles it before any other passthrough,
keeps today's wire code and message byte-identical, and adds { refusal: { code, cause } }
to the error's data. The hold throws it, and restart-resume files the cause beside the
unchanged reason. The operation ledger stores the cause beside the code, so a replay names
the same situation as the first answer. The sto…
…blyai#23677)

* test(claude): expect typed cancellation in queued-send settlements

* fix(ci): respect disabled terminal links and await browser recovery

* test(e2e): give legacy close client a profile authority
stablyai#23689)

On narrow windows the right-side status labels (update ready, memory,
keep awake) went icon-only while every agent stayed in full, so the +N
badge never appeared until much narrower. Add a density level that
collapses calm agents into +N while the right side keeps its labels;
it fits by the width with urgent agents pinned, and only when even that
doesn't fit do the segments go icon-only.
…stablyai#23676)

* fix(mobile): truncate oversize markdown reads instead of failing them

The desktop bridge refused markdown over a private 512 KiB cap with
file_too_large, which reached the phone as a generic runtime_error that
the reader discarded, so a 632 KB file showed "Couldn't load markdown".
Reads now return a UTF-8-boundary prefix under one shared 2 MiB budget,
marked truncated with the full byteLength and read-only. The phone shows
the truncation like file tabs do and maps refusal codes to real copy, so
an older desktop's refusal reads "File too large for mobile preview".

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a truncated markdown read is never editable

The read budget sits above the edit budget, so every truncated document is
already read-only as file_too_large. Pin that ordering so a future budget
change cannot make a prefix editable, and name the constant as the markdown
preview budget, separate from the file preview's own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refuse saves from a truncated markdown read

A phone holding a truncated prefix must not write it back; the 256 KiB
save guard refuses it as file_too_large before any version check. The
shared budget test shrinks to the ordering it pins and names the bridge
test as the behavioural pin.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): tighten markdown truncation types and measure once

The read truncation measures the document once. The disk fallback drops
its truncated-only read-only text, which the status line never showed,
and both truncation fields are optional there. A markdown doc's flag is
only ever true, and the schema comment names the hook, not line numbers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): drop a stale disk-fallback comment

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore: retrigger CI after stablyai#23675 landed on main

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…tablyai#23685)

* ci: benchmark per-job Node compile caching on full unit shards

* ci: measure unit shards with three and four workers

* ci: benchmark localization extraction CLI patch

* perf(build): reuse identical relay bundles across platforms

* ci: compare Vitest 4 and 5 on complete ARM shards

* perf(ci): upgrade localization extraction to skip irrelevant syntax walks

* perf(ci): use all four ARM cores and remove benchmark workflows

* ci: preserve failures while capturing unit source revision

* fix(ci): preserve commented and escaped localization calls

* ci: remove corrected localization benchmark harness
* fix(sidebar): move agent child disclosure to the timestamp slot

* fix(sidebar): align agent disclosure with summary caret
…e shell (stablyai#22762)

* fix(mobile): start AI-button agents through agent.launch, never a bare shell

"Fix checks with AI", "Resolve conflicts with AI", commit-failure recovery and
diff review's "New Agent Session" created a terminal with no agent and typed the
multi-line prompt into the shell, so each line ran as a shell command.

They now call agent.launchReplay into the existing workspace with the prompt;
the host picks chat or terminal from the user's default and delivers the prompt.
Hosts without the launch capabilities get the buttons disabled with update copy.

The agent comes from the desktop's own resolution (moved to src/shared). The
replay loop and capability read are shared with the workspace-create launch.

* test(mobile): repin bridged-parity tallies for the AI-button launch goldens

The corpus goes from 787 to 790 goldens: five shell-path goldens are removed and
eight agent.launch ones added; one lands in identical and two in
result-absent-settlement.

* test(mobile): re-record goldens for AI-button launches through agent.launch

Repinned baseline to 514ab7f and re-recorded all goldens. Against the branch
point: 781 header-only (baseline on all; adapterSha256 on the 48 goldens whose
adapter module changed; scenarioSha256 on 3), one body moved
(pr-triage-launch: createTerminal + terminal.send becomes agent.launchReplay),
eight added (the new launch outcomes and their reply matrices) and five deleted
(the shell-path scenarios and their matrices).

* fix(mobile): show review notes' agent launch progress and failures, once

"New Agent Session" left the sheet open with no progress for the whole launch
(up to a minute while a terminal agent readies), so a second tap started a
second agent, and a launch that never started or could not be confirmed
rejected an unobserved promise, showing nothing. The sheet now closes on tap,
the review screen says "Starting an agent...", one launch runs at a time, and
every outcome lands in the review screen's status line.

Marking notes sent now reads the screen state when the launch settles, so a
note written during the wait is not dropped by the whole-list save.

* test(mobile): re-record goldens for review notes' launch outcome on the review screen

Repins the corpus to 6f30185. One golden body moves:
review-create-agent-refused now fulfils with "Workspace not found" in the
review screen's status line and the sheet closed, where it previously
rejected an unobserved promise and left the status line empty. The other
789 goldens move only their baseline header.

* test(agent-status): drop the retired PR-triage terminal send from the identity inventory

The phone's AI buttons no longer create a terminal and send the prompt into it
(`createTerminalAndSendPrompt` is gone); the host's agent launch delivers it.
There is no terminal action consumer left in that file to pin.

* fix(runtime): publish saved source-control launch recipes to paired clients

settings.get is an allowlist and omitted sourceControlAi, so the phone never
saw an agent saved globally for "Fix checks", "Resolve conflicts" or commit
recovery and always fell back to the default agent. The host now publishes
the launch actions' recipes (agent, prompt template, agent args), normalized
so legacy saved defaults are already migrated. A new optional reply field:
older clients ignore it, and a client talking to an older host sees none and
keeps using the default agent.

* fix(mobile): ask to update Orca only when the host answered without agent launch

An unread or failed status read settles with no capabilities, which the AI
buttons read as an old host and showed "Update Orca on your computer". The
update copy now needs a status the host actually returned; an unread one keeps
the buttons disabled without blaming the desktop's version.

* fix(mobile): send an AI button's saved agent arguments with its launch

The desktop's direct launches for "Fix checks", "Resolve conflicts" and commit
recovery pass the action's saved agent arguments to agent.launch; the phone
honoured the saved agent but dropped its arguments. It now sends them the same
way: absent when none are saved, so the host keeps the user's configured
defaults. A host that predates the field ignores it.

* test(mobile): repin the RPC recording corpus after the launch recipe and availability fixes

Repins to fe85e03. All 790 goldens move only their baseline header: no
scenario saves agent arguments or reads an unreadable status, so no recorded
behaviour changes.

* fix(mobile): say the host status is unreadable instead of nothing when it is

With the update copy now reserved for a host that answered without agent
launch, an unread status left the AI buttons disabled with no explanation.
They now say "Could not read this host's status. Go back and reopen it.", the
words the mobile web shell already uses for the same failure; leaving the host
re-reads its status.

* fix(mobile): wrap an AI button's prompt in the action's saved template

The desktop renders every source-control launch's prompt through the action's
saved template (buildSourceControlRecoveryAgentCommandInput); the phone sent
its built-in prompt as is. Now that the host publishes the recipes, the phone
renders through the same shared function, refuses an empty result as the
desktop does, and offers the rendered text when it could not be delivered.
Review notes have no recipe and are unchanged.

* test(mobile): re-record goldens for the templated AI-button prompt

Repins to 7fd1555. One golden body moves: pr-triage-prompt-not-delivered
now carries the prompt as sent (rendered through the action's template) on its
prompt-not-sent result, which is what Copy prompt offers. The other 789
goldens move only their baseline header.

* fix(mobile): re-read a host status that failed while the connection stayed up

A status.get that timed out or was cut over settled the host's gates closed
and was never asked again until the connection state changed, so the phone's
AI buttons stayed disabled behind "Could not read this host's status" on a
link that was working. The gate still settles closed at once, so a failed
read never holds the host screen, but it now re-asks in the background with
the same backoff the runtime capability probe uses, and opens once a status
lands. A reply this app cannot decode is not re-asked.

* test(mobile): repin the RPC recording corpus after the host status re-read

The status gate change moves no recorded behavior: every golden's body is
unchanged and only its baseline header moves to the new pin.

* fix(mobile): show a launch's host warning as a note, not an error

A launch that went ahead can carry a host warning (a structured chat ignores saved agent
arguments, including the '' a template-only save writes). The AI buttons rendered it in the red
error line beside a success haptic. The notice now carries it separately as secondary text, and
review notes keep saying they were sent. Commit recovery also takes the synchronous in-flight lock
the PR triage buttons use, so two taps before a re-render start one agent.

* chore(mobile): record the host status re-read timer for React Doctor

The status re-read arms one retry timer from inside its read and clears it in the effect's
cleanup. React Doctor reports that self-rescheduling shape even in its minimal form, which failed
both changed-lines gates. Suppressed the same way as the session startup timers.

* fix(mobile): say review notes are waiting for the desktop instead of doing nothing

With no live connection, New Agent Session threw from a handler whose promise the sheet drops, so
the tap did nothing visible while the button stayed enabled (proven capabilities survive a drop).
It now closes the sheet and shows "Waiting for desktop..." as the other AI buttons do.

* fix(mobile): stop sending an AI button's saved agent arguments

Whether saved arguments apply depends on the route and shell the host settles after the request
(a chat ignores them and warns; malformed ones fail after admission), and the desktop sends them
only when they apply. The phone cannot know that, so it now leaves them out and the agent's default
arguments apply, as before this series. The saved agent and prompt template still apply.

* fix(mobile): mark review notes sent through the latest save

The sent marks after an agent launch went through the save callback captured at tap time, whose
rollback restores the screen from that moment, so a failed save could drop notes written during
the launch. It now uses the latest render's save, as it already did for the screen state.

* refactor(mobile): own the host status re-read outside the effect

The re-read loop lived inside the effect body, so React Doctor could not see its cleanup and
needed an inline suppression plus a config allowlist entry. The loop is now a plain function that
returns its stop handle, and the effect returns that handle, the same shape every caller of the
runtime capability probe uses. Both suppressions are removed; behaviour is unchanged.

* test(mobile): record the host descriptor from a background status re-read

Pins that the status read records the host descriptor when a re-read succeeds after a failed first
read, not only on the first answer.

* fix(mobile): show a PR AI launch notice only under the button that launched it

Fix checks and Resolve conflicts shared one error, warning and undelivered prompt, so a Fix checks
launch whose prompt was not sent also offered "Copy prompt" under Resolve conflicts, copying the
fix-checks prompt. Notices are now kept per button. The host availability notice stays under each
disabled button, since it explains why that button cannot be tapped.

* fix(mobile): say the host status is being retried instead of asking to reopen it

The host status gate now re-reads a failed status in the background, so "Go back and reopen it"
asked the user for a step that is no longer needed. The review sheet hint uses the same words.
The mobile web shell keeps its own copy.

* refactor(mobile): run the host status gate on the shared status probe

The gate had its own copy of the status probe's retry loop (same delays, same cutover and backoff
split, same stop on an undecodable status). The probe now takes an optional callback for each
failed attempt, which the gate uses to settle closed on the first failure, and the duplicate loop
and its now-unused reader are removed. Existing probe callers are unchanged.

* test(mobile): repin the RPC recording corpus after merging main

Re-records every golden against the merge commit and drops the three goldens whose
scenarios this branch removed, which the merge had restored from main.

* test(mobile): re-record the RPC goldens on the merge with main

Conflicted goldens were seeded from main and re-recorded against the merged
tree; every value either side recorded survives except main's terminal.send in
the PR triage launch, which this branch removes. Drops three goldens main still
had for scenarios this branch deleted.

* feat(mobile): confirm an AI button's agent started, naming the workspace

Fix checks, Resolve conflicts and commit recovery now show "Agent started in
<workspace>" under the button once the host started the agent with its prompt,
so a tap is no longer silent. The workspace label comes from the Source Control
panel and falls back to the branch.

* test(mobile): repin the recording baseline to the success-confirmation commit (header-only)

* fix(mobile): name the workspace in the diff review's AI-button confirmation

The diff review screen mounted the PR sidebar without a workspace label, so
"Agent started in ..." under Fix checks and Resolve conflicts named the branch
while the screen header named the workspace. The sidebar now requires the label
so no screen can drop it, and the diff review passes the one its header shows.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit, the last commit to touch a fenced
path. Against this branch before the merge, only header fields move:
baseline on every golden, and adapterSha256 on the 14 review-action goldens
whose adapter main now drives through the review sheet state. No recorded
body changed.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit, the last commit to touch a fenced
path. Against this branch before the merge only the baseline header moves,
on every golden; no recorded body changed.

* test(mobile): give the send-sheet stacking test the review controller's host status inputs

The merge with main brought in stablyai#22951's stacking test, which builds the review
controller without the host capability and status inputs this branch made
required, so the mobile test typecheck ratchet failed.

* test(mobile): re-record the RPC goldens on the merge with main

Repins baseline to the merge commit 03995ae, the last commit to touch a
fenced path. Against this branch before the merge only headers move: baseline
on every golden, and adapterSha256 on the 14 goldens recorded through the
terminal adapter main changed in stablyai#23080. No recorded body changed, and the
merged corpus differs from main exactly as this branch did before.
* test(e2e): await renderer recovery after worker exit

* test(e2e): publish worker recovery through authenticated hooks

* test: keep retired background worker dormant before activation
…yai#23678)

* test(claude): expect typed cancellation in queued-send settlements

* fix(ci): respect disabled terminal links and await browser recovery

* test(e2e): give legacy close client a profile authority

* test(e2e): account for frame pacing in pointer latency budgets

* test: compare pointer timing on isolated visible display
…d shared hooks dir (stablyai#23500)

* fix(opencode): stop OpenCode 2 loading a stale plugin from the retired shared hooks dir

Before 1.4.209 Orca pointed OPENCODE_CONFIG_DIR at <userData>/opencode-hooks/shared
and wrote a server()-only status plugin there. 1.4.209 moved the plugin to OpenCode's
global config dir and 1.4.210 added the v2 setup() export, but nothing rewrote the
old file. Shells, daemon-persisted panes and OpenCode 2 background services that
still carry that OPENCODE_CONFIG_DIR load only that dir under OpenCode 2 (it replaces
the global dir), so the v2 loader rejects the stale plugin with "Plugin must export a
default definition with an id and an effect or setup function" and pane status dies.

- Refresh the plugin in the retired shared dir (only when it already exists and its
  content differs) so OpenCode processes started later from old shells load the dual
  v1/v2 export. Runs on OpenCode pane spawns and on any spawn that inherits the
  retired dir, even with agent status hooks off.
- Drop an inherited OPENCODE_CONFIG_DIR / ORCA_OPENCODE_* marker that points at the
  retired dir when building a new pane env, so new panes use global discovery.

Limitation: an OpenCode 2 background service already running from an old pane keeps
its cached copy of the stale module even after the file is rewritten (verified with
opencode2 v2.0.18). It must be restarted (`opencode service restart`); a restart from
a new Orca pane then picks up the global config because the env is stripped.

* fix(opencode): harden legacy plugin repair and inherited config cleanup

* fix(opencode): preserve daemon-owned user config during legacy cleanup

* fix(opencode): sanitize inherited sources and repair unseen legacy copies

* test(opencode): update shared PTY mocks for legacy repair

* test(opencode): annotate shared repair mock signature

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
… status store (stablyai#22553)

* refactor(native-chat): the Codex acquire names its turn-boundary methods as a set

Behavior-neutral: the same two methods stamp receipt time. Keeps the file
under the size limit once the child-work sink lands.

* feat(native-chat): Codex sessions write their subagents into the host status store

A Codex child thread and each persistent command become host child records,
fed through the same delivery, ingest and reducer the Claude lane uses. The
child's own turn decides it: turn start is live, turn completion settles it
with the outcome Codex reports, and a follow-up turn reopens the same record
as a new run. Its open tool call, last message, usage and waiting-on-user flag
come from its own thread's frames. A parent turn ending settles nothing.

* fix(native-chat): close a Codex child's tool call by its item id alone

A completion frame need not restate the tool it ran, so reading the tool name
before closing left the call open and the record naming a finished tool.

* test(native-chat): pin the Codex child-work evidence and every hop to the host's records

Child turn start/end/follow-up, open tool call, last message, usage, waiting,
the persistent command a child owns and its monitoring display, a primary
turn end settling nothing, and session end. End to end through the real
adapter: evidence after the journal and the legacy republish, and the parent
state the records imply equals today's at every frame of a scripted session.
Through the production runtime: a Codex session's child work reaches the
status sink under its own address, and a provider exit ends it there.

* test(native-chat): a Codex child's new run never inherits the last run's open call

* test(native-chat): a Codex session with no child-work sink holds no evidence

* test(native-chat): deliver a Codex child's announcement twice, as Codex does, before counting edges

* refactor(native-chat): hand the Codex producer's pending edge over directly

* fix(native-chat): name every Codex turn state in the outcome map; type the runtime test's fake opener

* fix(native-chat): a Codex child's turn ends on the error that ends it, or on its thread closing

Codex can end a child's turn with no turn/completed: an error it will not
retry is that turn's own end (the verdict the transcript already settles the
same turn on), and a closed thread ran its last turn. The executions, the one
owner of child turn state, now end the turn on both, so the strip drops the
child and its record settles (failed, or unknown for a close) together,
instead of reading working for the life of the session. A systemError status
is not an ending: Codex raises it for errors that leave the turn running.

A child fact whose frame names no turn now belongs to the turn the child is
running, instead of counting for every run.

* test(native-chat): a Codex child's turn ending by fatal error or thread close settles strip and record together

* test(native-chat): the Codex parity script reads a waiting child through the shared fold's waiting arm

* test(native-chat): a Codex child row's journal attempt is its record's generation

The journal numbers a Codex child's runs by the turns it observed on the
child's thread; the host record numbers them by the runs its evidence
opened. Both are keyed by the child's own turn id, so they must agree run for
run, including when Codex reports the child's first turn before the spawn
that announces it.

* test(native-chat): a Codex session's end settles its live children and keeps the ended ones

The host no longer erases a session's children when its provider goes away: a
child still running settles with an outcome nobody reported, and a child that
had already ended keeps what it said. The producer tests now expect exactly
that, from the close path and from an unexpected exit.

* fix(native-chat): a Codex subagent's shell is its open tool until the process exits

Codex runs every agent shell through unified exec, so every subagent shell
arrives with the source the persistent-command tracker keys on. The producer
skipped those items, so a working subagent never named its shell, and an
approved command (started on the approval path, completed from unified exec)
stayed its open tool until the turn ended. The tracker still records the
process separately, so a command that outlives the turn reads as monitoring.

* fix(native-chat): a Codex shell becomes a subagent's own work only once it outlives its turn

Codex runs every agent shell through unified exec and never says when one is
left running, so the producer turned every shell, even a millisecond `rg`, into
a command record the moment it started. Each settled into the session's pool
of 32 settled records, so a busy turn evicted a finished subagent's record
(its outcome row would vanish) and listed dozens of finished shells beside it.

A command now becomes a record at the first turn boundary of the thread that
launched it while its process still runs: until then it is the agent's open
call. A shell that exits within its turn never becomes a record.

* refactor(native-chat): child records keep every settled child and can be removed outright

Settled child records now stay until the host drops the session's row; the
32-record trim is gone. A producer can say work stopped with nothing to
report, and its record (and the handles it answered to) goes instead of
settling. Evidence stays host-internal: the producer and the store share
one process.

* fix(native-chat): a Codex command is live work from its start until its process stops

The command tracker is now the one owner of a Codex command's lifetime. It
admits every command whatever `source` Codex tags it with (the approval
path starts one as `agent`), and ends it when its process exits, when its
thread closes (Codex stops the processes first, so no exit ever arrives),
or when the session ends. The producer mirrors that one-to-one: a live
record from the start, removed when the command stops, never settled.

This removes the turn-boundary rule: a command that was only recorded at
its turn's end left the parent reading done for one publish when the main
agent's turn ended with a shell still running. The parity script now
checks the parent at every journal write, not only at frame end.

* fix(native-chat): a Codex command whose approval its turn abandoned never ran

Codex starts an approval's command item before it asks, and when the turn
ends with the question unanswered (the user stops at the approval), it drops
the question and never completes the item. The command tracker admitted that
start as a running process, so the strip kept a phantom command row and the
session row read working until the session ended.

The prompt registry, which owns which approvals are still unanswered, reports
the command approvals a turn ended without; the tracker ends those commands
with the frame that ended the turn. An answered approval keeps its command.

* test(native-chat): start the Codex child-work runtime test without the removed hold

Main no longer has host.hold: creating the session starts its child, and
nothing a viewer does keeps it running. The test attaches and asserts the
one child that attach started, then drives it as before.
stablyai#22568)

* feat(orchestration): inject the Orca session id into structured children and let the CLI act as it

Every structured session's child (native Claude, native Codex, and the terminal
view) carries ORCA_AGENT_SESSION_ID and reaches the Orca CLI. The CLI sends the id
in the orchestration envelope; when present it is the caller, and a caller flag
naming anyone else is refused before any request. The id is stripped from
inherited PTY env and from the SSH host-CLI passthrough, and crosses into WSL so
the host can refuse the cross-host claim.

* test(orchestration): pin session id injection for native Claude, native Codex, the terminal view, WSL, PTY inheritance and SSH

* test(orchestration): pin one caller precedence rule across every CLI verb that names its caller

Adds the per-verb table (flagless acts as the session; a conflicting --from or
--terminal is refused before any request; the session's own spellings are
accepted), the enumerated guess population with its positive control, the
structured worker's own handle, the identity-less refusal for an older child,
the unchanged terminal agent, and the envelope. dispatch-show's --from only fills
preview text, so it passes through unfenced and a session's flagless preview
names the address the real dispatch writes.

* refactor(orchestration): keep the identity-less marker reader to the marker; the id is checked first

* test(orchestration): pin that a host refusal of the session surfaces verbatim from the CLI

* fix(orchestration): keep the identity-less marker beside the id for CLIs that predate it

A CLI older than the id, reached through a global install when a shell rc resets
PATH, would otherwise guess a sibling's terminal in a chat that no longer carries
the marker. It refuses on the marker instead; a current CLI checks the id first,
so the marker never makes a session with an id identity-less.

* fix(orchestration): refuse a conflicting --from on gate-list and task-list scoped by --run

A --run listing needs no caller, so both handlers skipped the resolver and a
--from naming another actor was dropped silently under a session. The conflict
check now runs on that branch too; terminal callers are unchanged.

* fix(orchestration): name this app's CLI by absolute path for a structured session's login shells

A provider can run each command in a login shell: Codex runs zsh -lc, and the
profile rebuilds PATH, putting a global install (possibly an older Orca) ahead of
the directory Orca prepended. ORCA_CLI_COMMAND, which an agent resolves the CLI
from first, is now the absolute launcher in that directory (the native launcher
on Windows), so no shell's startup files can swap it. The PATH prepend stays for
shells that read no profile. Found by the live coordinator run of the next PR.

* test(orchestration): pin a structured worker's CLI command as this app's absolute launcher

* test(orchestration): run the zsh login-shell arm in the real-shell lane that installs zsh

The ordinary Linux unit lane has no /bin/zsh, so the zsh arm failed there with
ENOENT. It moves to a live-shell file registered in the shell-contracts lane; the
bash arm keeps running in every lane. The lane guard's detector now also sees a
zsh spawned through the ProcessSpec program field, which is how this test
escaped it.

* fix(orchestration): omit a structured child's CLI command when no launcher resolves, and pin its instance

A bare `orca` fallback named GNOME's screen reader on packaged Linux, and an inherited value named
another app's CLI. The builder now deletes any inherited value, sets the absolute launcher only when
one resolved, and pins ORCA_USER_DATA_PATH so a current CLI dials the instance that minted the id.
Renames the marker reader to hasStructuredSessionMarker and records why the terminal view carries
the id without the marker.

* fix(terminal): name this app's CLI launcher by absolute path in every local terminal

ORCA_CLI_COMMAND meant three things by lane: an absolute launcher for a structured session, a bare
name for WSL, and nothing for any other terminal, so a structured session's terminal view lost it.
Local terminals now get the same absolute launcher the structured lane gets; WSL keeps its guest
command name, and a terminal whose launcher does not resolve still gets none.

* feat(cli): hand a command to the session's own CLI when another Orca CLI was invoked

A login shell can reorder PATH behind a global install, and an agent or its helper script can run
bare `orca`, so the binary that answered depended on the agent following instructions. Orca's
packaged launchers and bare-orca shims now export ORCA_CLI_SELF (outermost wins). At the CLI entry,
when it names a different launcher than ORCA_CLI_COMMAND, the command re-runs once through the named
launcher with ORCA_CLI_REEXEC=1 and exits with its status; both variables are consumed so no child
inherits them. Dev launchers export no self on purpose, WSL and SSH names never qualify, and a
launcher that cannot start leaves the command to run here. The Windows launcher no longer rewrites
ORCA_CLI_COMMAND; the legacy ask protocol normalizes its resume command itself.

* refactor(orchestration): declare which flag names the caller on each spec and refuse at the CLI entry

Each handler hand-classified its --from/--terminal as the caller or a target, and the refusal of a
conflicting caller flag ran inside the caller resolver plus two standalone calls for --run listings,
so a new verb that read its flag raw would pass a sibling's handle to a pre-session host. Specs now
declare identityFlagRoles, the CLI entry refuses a conflicting caller flag once from the spec, the
resolver only applies the id-wins rule, and a test fails any orchestration verb that accepts --from
or --terminal without classifying it.

* perf(cli): keep the session caller check off the actor codec's module graph

The check runs at the CLI entry for every command, and the actor codec pulls zod through the session
record. Compare the session's own spellings as plain strings instead.

* refactor(cli): spell a session's address from the one prefix constant, off the codec's module graph

The Orca session address prefix moves to a leaf module with no imports, re-exported by
the address codec, so the CLI entry check derives `session:<id>` from that constant
instead of re-typing it and still stays off the codec's zod graph. Prose and test names
say caller or Orca session id, not actor.

* refactor(orchestration): drop the session id's terminal-view spawn now that the handoff is gone

The terminal handoff was removed, so no terminal is ever a structured session:
- delete the terminal-view identity env and its WSL passthrough, and their tests;
- strip the session caller keys from every terminal's env unconditionally;
- the CLI's own-address spelling moves beside the injected id in src/shared, with
  a test pinning it to the address the host's party resolver gives that session.

* fix(terminal): run the Codex launch preflight through the CLI the terminal names

Packaged Linux names the userData shim in ORCA_CLI_COMMAND, while the preflight
ran the bundled launcher behind it. The CLI saw a different launcher and handed
the preflight off to the shim, booting Electron twice before every codex launch.

* revert(terminal): keep terminals on main's ORCA_CLI_COMMAND and Codex preflight

Only a structured session needs an absolute ORCA_CLI_COMMAND; local terminals go back to
naming none (WSL keeps its guest command), and the Codex launch preflight goes back to the
bundled launcher. The CLI handoff is scoped to sessions, so a terminal's preflight can no
longer be handed off and start Electron twice.

This reverts commit d2cefb6 and commit dd2853a.

* fix(cli): hand off to the session's CLI only inside a structured session

The handoff ran whenever an Orca launcher's ORCA_CLI_SELF differed from an absolute
ORCA_CLI_COMMAND, so any process with both - a terminal, a script - ran another install's CLI
instead of the one invoked: a beta's --version lied, and an AppImage command from a terminal
that outlived its Orca failed. It now requires the injected session id, the identity it exists
to deliver. The launcher variables are still consumed in every process.

* fix(cli): name the packaged Windows command after the handoff decision

The launcher stopped writing orca/orca-ide over ORCA_CLI_COMMAND so the handoff could see a
session's absolute launcher, which also changed what every Windows terminal's CLI read. The CLI
entry now applies the launcher's rule itself once the handoff is decided, so terminals and the
legacy ask resume command see exactly what they saw before, and the resume-command reader
goes back to its original form.

* refactor(cli): decide the session handoff from the CLI's own entry, not a launcher export

Every packaged launcher, shim and dispatcher exported ORCA_CLI_SELF so the CLI could tell which
launcher ran it, and compared that with the session's ORCA_CLI_COMMAND. Two launchers of the same
app are different files, so a session that reached its own app through a global orca-ide on Linux
still handed off and started Electron twice, and the export rode artifacts every terminal uses.

A structured session now also names the JS entry its launcher runs (ORCA_SESSION_CLI_ENTRY), and
the CLI compares its own argv entry with it: any launcher of the same app stays, another install
hands off. The launcher scripts, Linux shim and dispatcher go back to main; the Windows launcher
keeps only leaving ORCA_CLI_COMMAND for the CLI to name after the handoff decision.

* refactor(cli): drop the session CLI handoff; the pinned instance and injected id already bind any current CLI

Every current Orca CLI dials the instance ORCA_USER_DATA_PATH names and sends the injected
session id in the orchestration envelope, so a bare `orca` that reaches another install's
current CLI already acts as the session. An older CLI has no handoff code and refuses on the
marker. The handoff only lined up versions between two current CLIs, and comparing two
separately derived paths kept misfiring (an AppImage's mount against its registered
extraction started the CLI twice on every call).

Removes the re-exec, ORCA_SESSION_CLI_ENTRY and ORCA_CLI_REEXEC, and the CLI-side Windows
command naming; the packaged Windows launcher rewrites ORCA_CLI_COMMAND again, as on main,
inside its own process only. resolveHostCliEntryPath goes back to the SSH passthrough.

* test(orchestration): say why the registered worker case pins the handle, now that every session's env is populated
… spawn tag (stablyai#23460)

* fix(native-chat): stop signalling processes that only inherited a spawn token

A spawn token is an environment variable, so every descendant of a provider child
carries it. The Linux-only startup scan treated any carrier no lease claimed as a lost
provider child and sent it SIGTERM, which also hit editors, tmux servers and nested
Orca processes the agent had started. Remove that scan's killing consumer; the token
scan stays for the reservation probe, and recorded owners are still stopped by
identity during recovery.

* fix(codex): remove the token-scan kill path from app-server teardown

Every descendant inherits the spawn token, so killing each pid that carries it can
reach processes the agent started that are not the provider. Production never
injected this path; teardown always uses the process-group and descendant-snapshot
proof. Drop it, its deps, and the now-unused spawn-token argument.
…, never the parent's (stablyai#23605)

* fix(native-chat): a subagent's words are presented as that subagent's, never the parent's

The journal already names the agent that produced every row, but the transcript
projection dropped it, so a subagent's prose rendered as the parent's reply, its
tool calls folded into the parent's runs, and a settled turn could fold down to
a subagent's words as its only visible answer.

The transcript message now keeps the row's producer. The fold keeps each agent's
calls in that agent's own run, a turn's answer is the session's own agent's last
prose, and a subagent's row names the subagent on desktop, mobile and a worker's
transcript text.

* test(native-chat): give the window fixture's slot the attribution field it now carries

* fix(mobile): read the subagent label the row is given, and pin the caption

* fix(native-chat): keep interleaved agents in order and each agent's own run live

Review follow-ups:
- the fold is main's adjacency fold plus one condition: a row never folds into
  another agent's run, so an agent's later call stays below its subagent's work
  instead of jumping back into its earlier row
- each agent has its own live frontier, so a parent still inside its spawn call
  reads as running while its subagent works below it
- mobile names no one on a row whose only content is hidden behind its settled turn
- a pending question from a subagent keeps its producer
- worker reads serve only the producing agent's id, bounded like the roster key
  that names it, and drop the provenance fields
- the single-message worker formatter is private, so no caller can drop names
…ly while visible (stablyai#23592)

* fix(tab-group): measure fallback pane geometry once per tab group, only while visible

* refactor(tab-group): derive the shared resize listener's lifetime from the source map

The map already drops empty groups, so a separate counter was a second copy that could disagree.
…zing both (stablyai#23585)

Status-row change detection stringified two full IPC payloads on every
status write, including an up-to-8 KB lastAssistantMessage re-posted on
every OpenCode streamed part. Compare the same published field set with
the existing structural-equality helper, with a same-reference fast path.

Linear: STA-7432
…ablyai#22644)

* fix(ssh): don't overwrite remote agent config after a failed read

A flaky read was treated as an empty file, wiping the user's config.

Fixes stablyai#22638

* test(runtime): model remote missing-config reads as relay ENOENT errors

The runtime harness stubbed isENOENT as code-only, and the remote Codex
startup specs rejected with a generic error that only passed while any
read failure seeded an empty config. Use the real isENOENT and the
message-only shape the relay actually delivers.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
…stopped (stablyai#23466)

* fix(codex): the provider supervisor outlives its provider group when stopped

A signalled supervisor forwards the signal to the provider group, escalates to
SIGKILL after the grace, and exits only once the group is gone, so recovery's
proof that the recorded pid is dead also proves the provider is. It refuses to
spawn when its parent is already not the owner named in its spec, and watches
that owner rather than whichever parent it first saw. The grace is a spec
field. Recovery's SIGTERM stage now outlasts the supervisor's own stop, since a
SIGKILL that lands first cannot be handled and leaves the group running.

* fix(codex): a closed owner pipe no longer ends the supervisor before its provider group

When Orca dies, the supervisor's stdout pipe has no reader. Provider output in the
window before the parent-death watch fired raised an unhandled EPIPE that exited the
supervisor with the provider group still running.

* fix(codex): bound the supervisor grace so recovery's SIGTERM stage always covers it

Recovery sized its SIGTERM stage from the default grace, so a launch with a
longer grace would be SIGKILLed mid-stop and orphan its group with no test
noticing. The spec now refuses any grace above one exported maximum, and
recovery derives its SIGTERM stage from that maximum.

* fix(codex): every supervisor stop asks the provider with SIGTERM first

Owner death, stdin end after the grace, and a signal to the supervisor now all
take one path: SIGTERM the provider group, SIGKILL it after the grace, and exit
only once it is gone. The signal handlers are registered before the provider
is spawned, so a stop that lands in the spawn window still reaps it. The
longest stop grows to two graces plus the reap wait, and both recovery's
SIGTERM stage and the connection's graceful close now wait that long before
forcing, since forcing the supervisor sooner can orphan its group.

* fix(codex): give the provider 1 s after stdin end and 3 s after SIGTERM to flush before SIGKILL

The supervisor's stop was stdin end, 1.25 s, SIGTERM, 1.25 s, SIGKILL. Codex
now gets 3 s after SIGTERM to flush its state. The two graces are separate
constants, the longest stop they derive becomes 5.5 s, and a test keeps it
inside quit's 8 s child-eviction bound.

* test(codex): count eviction's pre-stop drain in the quit budget test

Eviction drains the sink for up to 1 s before it stops the child, inside the same 8 s bound.
…ai#23492)

* fix(runtime): retire an exited terminal before its stream end

An exit's durable retirement became asynchronous, so onPtyExit released
the terminal stream before the retirement landed. A paired client answers
a stream end by re-activating its pane; that activation still found the
exited leaf, materialized it under the same session id, and registerPty
dropped the pending retirement. The exited split pane came back as a
fresh shell.

The exit now stages the retirement into the in-memory session and
publishes it synchronously, then notifies exit listeners, and only then
makes it durable. A failed durable write is logged and left in memory for
the next profile write instead of being rolled back, since the process is
gone either way. This removes the pending-retirement latch and its
post-await incarnation fence: there is no longer a window for them to
guard.

* test(runtime): a failed exit retirement still reaches disk

Pins the no-rollback contract through a real Store and SQLite authority:
when the retirement's own durable write fails, the in-memory retirement
is carried by the next unrelated profile write, and by the app-quit
flush when no other write happens. The delayed authority fixture can now
fail its next write, and the acknowledged-retirement fixture reads the
database a relaunch would load and models the quit flush.

* test(runtime): a stream end observes the exit retirement already published

The re-activation check alone passes with the listener ordering reverted,
because activation awaits before its lookup. Record the session binding
and publication count at the moment the exit listener fires so the
ordering itself is pinned.

* fix(runtime): an exit cleanup fault still ends the terminal stream

* perf(runtime): exits retired together share one durable write

* test(runtime): a refused staging write still retires the pane and ends the stream

* refactor(runtime): describe exit retirement as staged, not durably accepted

The retirement result is staged in memory before any write, and the removable-surface comment and the replacement-admission test name still described the old publish-after-durable rule.
…ai#23701)

* fix(terminal): retain typing while a parked remote pane reattaches

* test: persist restored remote terminal screenshots

* fix(remote): buffer recovery reconnect input

* fix(remote): retain input across restored pane attach

* fix(remote): flush attach input after subscription

* fix(remote): flush reattach input after attach readiness

* fix(remote): stop buffering after reattach readiness

* test(remote): trace parked reattach input lifecycle

* test(remote): forward paired client lifecycle diagnostics

* fix(remote): preserve restored typing before connect starts

* chore(i18n): refresh runtime required catalog

* fix(i18n): ship compact agent runtime label

* fix(i18n): merge required label into existing sidebar catalog
)

* test(e2e): observe passive terminal restoration before activation

* fix(terminal): reveal persisted splits during source binding publication

* test(terminal): handle nullable persisted layout roots
…th no other rest signal (stablyai#23598)

* fix(runtime): reopen the quiet-foreground tui-idle lane for agents with no other rest signal

A tui-idle wait could never settle on a pane running amp, goose, crush, kimi,
qwen-code, rovo, auggie and other agents whose titles Orca cannot classify:
the quiet-foreground lane was closed for every launched agent, and it was the
only lane those agents could reach, so worker start failed at agent_readiness
after 60s.

Model each agent's rest signal, derived from the tables that already encode
it (synthetic ready titles, the title classifier, the DSH hook and Muse ready
screen lanes). The lane stays closed where a stronger signal will arrive and
reopens for agents with none. On a reopened lane, silence counts only after
the TUI has painted: an agent that has painted nothing is still booting.

Linear: STA-7440

* fix(runtime): count only the command's own output as an agent's paint on the tui-idle foreground lane

The after-paint lane accepted any output, and the shell's prompt and echoed launch
command always land before the agent starts, so a silently booting agent could still
settle and lose its first prompt. The runtime now reads the shell integration's
command-start marker and requires visible output after it; panes whose shell emits
no marker keep the any-output rule.

Also skip the foreground-process read while the pane cannot settle, and register the
new title-classifier call site in the pane agent identity inventory.

* fix(runtime): classify Freebuff's rest signal and skip the backward marker scan on chunks without one

Main added the Freebuff agent after this branch point, so the full per-agent rest-signal table no longer matched on the merge ref. Freebuff derives `none`: its screen reports a first-party `done`, which tui-idle trusts only for DSH, so the quiet-foreground lane is its only one.

The command-start scan ran a backward search over every PTY chunk; a forward check first cuts that to the cost of a plain substring test on chunks with no marker.

* fix(runtime): classify Qoder's rest signal after merging main

Main added Qoder with its own readiness branch returning a boolean quiet-foreground
flag; map it to the lane type and classify Qoder by its ready screen so the full
rest-signal table and lane-agreement check stay exhaustive. Say what `none`
actually means: no stronger lane tui-idle trusts, not no hooks at all.

* refactor(runtime): track command paint with the shared OSC 133 scanner

The command-paint tracker had its own split-unsafe 133;C parser. Reuse the
chunk-boundary-safe scanner, which now reports where in the chunk the marker
ended, so a marker split across reads is still found. Correct the unmarked-launch
list: bash and zsh mark typed launches after the echo.

* fix(runtime): drop command-paint state on an output gap or a new process

A dropped chunk can cut a command-start marker in half, and the scanner's
carry then completes it on unrelated output after the gap, leaving the
pane waiting for a paint that already happened. Reset it with the other
cross-chunk carries.

* fix(terminal): keep the command-start offset out of renderer lifecycle callbacks
brennanb2025 and others added 12 commits September 30, 2026 23:21
… starts its agent (stablyai#23935)

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): a message is accepted, then delivered

A send to a chat with no running agent restarted the agent inside the send
call, before the message was recorded, so the client waited for the whole
start and a failed restart refused the message. Claude held prompts sent
during startup, and those could settle as "unconfirmed".

A send is now accepted inside the session's serialized queue: one ledger row
and one submission row marked handoverRecorded, published, answered pending.
A per-session delivery loop exists while a message is queued. It starts the
agent through the same serialized attach a hold uses, waits outside the queue
for a Claude child to prove its start, and hands the oldest queued message
over as its own serialized step, writing dispatch{pending} before the adapter
call. A start it needed and did not get writes one error-tone row and rejects
every queued message with the same words; a start Stop cancelled writes none.

Settlement follows from the rows. A queued message is provably unwritten, so a
close, an eviction or an exit rejects it. A handed-over message stays in doubt.
A queued row at or below the sequence a handle found when it opened was left
by an earlier process and is rejected at open, with no latch. Stop withdraws
queued messages with no writer lease and no fence. An attach failure keeps the
conversation open, and the attach adopts its journal. Owed work counts the
loop and queued rows.

A compaction or rewind found prepared when a conversation opens was started
under a child this process no longer has, so the open settles it rather than
leaving it to refuse every send until a view attaches. The open cursor is
scoped to its epoch, because sequences restart when an epoch is replaced.

Deleted: restart-before-admission, recordFailedRestart, the fence rebase,
Claude's startup gate, the attach's forget on failure and its own crash
boundary. Clients without agent-session.accepted-send.v1 get their reply held
until the handover; the desktop and paired desktop lists advertise it.

* fix(native-chat): settle queued messages only for the child that ended

A child that proved its start and then exited before its message was handed
over left the message queued: the exit settlement returned early when nothing
else was in flight. Delivery then started another child for it, and a child
that died the same way started another, without end and without a row.

A retried settlement for an earlier generation, run by the attach that
delivery started, did the opposite: with that generation's turn unfinished it
rejected the message queued for the child being attached.

The settlement now takes the rejection for queued messages from its caller.
The unexpected exit and the eviction pass one, and it applies even with no
other work in flight; the retry for an earlier generation passes none.

* fix(native-chat): an adoption that fails to import keeps the conversation open

The attach now writes into the conversation's own open journal, but a failed
transcript import still closed it as if it were the attach's provisional one.
The conversation stayed indexed with a closed journal, so every later send
answered "could not be recorded" and every attach failed again until the app
restarted. The import now closes only a journal the attach opened for itself.

* perf(native-chat): the recovering open reads the journal once

Every conversation open now goes through the recovering open, including the
read restore of every chat at startup, which used to replay its journal once.
The recovering open replayed it twice: once to probe it and again inside the
open. The probe is now handed to the open as its load.

* fix(native-chat): an attach that fails after indexing its child leaves no child behind

A failed attach now keeps the conversation open, but a failure after
`onAttached` indexed the child (the rewind or compaction recovery, or the
attach's own success record) left that entry claiming a child the failure
path had already released. The next send found the phantom, skipped the start,
and wrote at a fence the journal had moved past, so the message stayed queued
for good. The entry now drops the released child and its event sink, and
follows the record's fence, as a failure before indexing already did.

* fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer

The error strip for a message the host accepted and then did not deliver matched the entry before
the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not
send your message" with nothing to retry. It now reads the reconciled entry.

A rejection the journal records before the send's own pending answer lands is final as well:
that answer no longer puts the entry back to dispatching with no Retry.

* fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down

The preamble waits for its submission to be delivered while the worker's agent starts. When that
wait ran out it threw operation_unknown, and the failed-start teardown then closed the session,
which rejected the very preamble the host was about to deliver. It now reports a turn start
nobody observed yet: the worker is start-unknown with its session kept, the host delivers the
preamble when the agent starts, and the worker's report settles the dispatch as for any
unobserved start. The receipt no longer suggests reading a screen a structured worker lacks.

* fix(native-chat): a message rejected while its chat was closed reads as not sent

A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it
meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked
every later message behind a Retry and no reason, and the delivery probe, seeing the journal
already answered, never ran. The reconcile now settles it as rejected like a dispatching one.

* test(orchestration): name why the readiness settlement fakes are cast

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

* refactor(native-chat): the provider child is its own record

A conversation now outlives any number of provider children, so the child is one record on the
conversation's entry instead of five loose fields beside its journal. It is written in one place:
indexed only once an attach has fully succeeded, and ended through one function that an exit, a
failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence.

- A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence
  patch after it are gone.
- Conversation writes read the record's fence, the way mutation admission already does; a child's
  own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of
  the conversation's fence, are gone.
- The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer
  dropped when an attach replaced the whole entry.
- Stop on a child still proving its start stops only the child: its lease goes back and the chat
  is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus
  the conversation's close.
- The settlement retry uses the conversation's own journal, opened through the host's one open.

* fix(native-chat): the delivery loop alone settles a message its start or child failed

A queued message was settled by whichever path happened to end the child first: the loop, the
unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that
rejected every pending row. That gave two failure rows with different tones for one start, a loop
that could hand over to a different child than the one it waited on, and a Claude start that died
while starting reading unlike every other failed start.

- The loop remembers the child it waited on. At handover, if that child is gone or replaced, it
  reads how it ended: a Stop continues; anything else writes one failure row and rejects every
  queued message with the same words, then stops. A child still starting whose start the adapter
  says did not land fails the same way. The exit, eviction and the settlement retry only settle
  the handed-over and legacy rows of the child that ended.
- One failure row, always an error, keyed by the start. A start a view began that dies with
  nothing queued writes the same row through the same builder, so a second report revises it.
- The open no longer rejects leftovers; the loop's first step does, and the open wakes it.
- `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the
  failure before the exit is processed.
- Quit closes every conversation the way closing a chat does: what is still queued is rejected as
  closed, with or without a child, and a start the loop already has in flight is waited for so the
  child it produces is stopped rather than left behind.

* refactor(native-chat): a stopped child ends on the one reading of its stop

The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that
verdict to the child's ending, so the host never forms a second view of whether the root is gone.
Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition,
and a failed re-attach passes what its release saw. The end-of-child record can therefore also
carry a stop whose root was not seen to go, which nothing ends on yet.

* feat(native-chat): the host says it accepts a send before any agent has it

The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same
string capable clients already send. A client can then tell a host that answers a send at
acceptance, and admits a Stop with no writer before a turn starts, from an older one that still
restarts the agent inside the send. Additive: an older client ignores a capability it does not
know.

* refactor(native-chat): an attach never opens a journal of its own

The attach adopts the conversation's open journal, which outlives it, so it no longer opens one
for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag
that told the two cases apart is gone. Tests that attach without a host open the conversation the
way a host does.

* fix(native-chat): a moved fence resends nothing on a host that accepts first

The outbox treated any fence change as a new owner: it dropped the answer of a send in flight,
queued that send to go out again under the same id, and unblocked a refused head. On an older
host that is how a send the restart refused, unrecorded, gets another try. On a host that records
every send before it starts an agent, a fence moves because that start ran, so the same rule
resent into every failed start. With a fence stamped on every frame, that became a loop.

The outbox now reacts to a fence change only when the host has not advertised that it accepts a
send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed
start reaches the client as a rejected message it keeps with its Retry. Against an older host, or
before one has answered, the outbox behaves as it did. Desktop and paired web share this hook.

* refactor(native-chat): a child's end says whether the user or the host stopped it

The end-of-child record's cause now tells a user's Stop from the host stopping the child for a
cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a
user's Stop, as before, and fails the start it was waiting on after a host stop, with the one
error row and every queued message rejected, in the stop's reason when it gave one. The reason
stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet.

* fix(native-chat): a chat whose only work is a queued message is not offered for resume

A message accepted while the agent was starting counts as working in the chat, and quit rejects it
as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a
chat whose agent never had the message. The snapshot now reads only what was handed over.

* fix(native-chat): the conversation outlives its agent

Opening a chat no longer starts its agent. A conversation is reached through one host
accessor that opens its journal at rest, and a send is what starts the agent, through
the delivery loop. One idle sweep, every five minutes, stops an agent that has been
quiet for thirty minutes and owes no work, then drops an open journal handle that is
only a cache. Its record, tab, status row and readers stay.

- hold and release are no-ops; hold still builds the host for shipped mobile builds.
- The holders, the holds, the release clock and the exit respawn are deleted.
- Options, the model list, the goal and the context meter answer at rest; a model pick
  at rest is recorded as intent for the next start.
- Compact, rewind, clear and goal changes start the agent first. A send does too when
  a rewind is still in doubt after the conversation opens.
- Orchestration routes mail and group addresses on ownership (the record plus the chat
  tab), not on whether the process runs. An open dispatch keeps its worker running.
- The restart continuation is a send; Resume all holds each slot until the message is
  handed over or rejected.
- A read error never replaces a loaded transcript, and shows the host's own words.

* test(native-chat): type the queued-message fixtures in the resume-offer tests

* fix(native-chat): a start that dies while a message waits on it is that message's failed start

Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When
that start died, its exit wrote the start's error row and left the message queued, so the delivery
loop started a second agent into the same failure and wrote a second row. A child's end now records
where the conversation's journal stood, and the loop settles a message accepted before a failed
start ended with that start: one row, under its key, and no second start. A message sent after the
failure still gets a fresh start.

* fix(native-chat): a request that failed reads as failed

A structured chat whose only message the agent's start refused read as a
green finish, and a cancelled structured turn did too: the host published a
verdict only for turn records, and structured rows carried no `interrupted`.

The host projection now reads the session's latest request: its turn's
outcome, or `failure` for a send the agent or its start refused. A send
that was withdrawn, or left undelivered by a restart or a close, fails
nobody and makes nothing listable. The ingest publishes `interrupted` as the
hook lanes do, and every reader decodes the verdict through one accessor, so
a failure reads Failed on the dot, the rollups, history and `worktree ps`,
behaves like a cancellation in every clean-finish policy, and notifies as
"failed".

* docs(native-chat): say what an attach's open conversation and unconfirmed ids are now

* test(native-chat): a verdict change republishes the mobile status projection

* refactor(native-chat): the store's retention trigger keeps its flag compare

A verdict change always moves the completion clock the same check already
reads, so a second verdict compare there caught nothing new.

* test(native-chat): a user message the provider journaled keeps its session listed

* test(native-chat): pin what a failed start settles, and what a resume offer names

A view's child that dies while a sent message waits settles that message only when it died starting
and no child has taken its place: a proven child's crash, or a second start since, gets the message
delivered. The resume offer names the handed-over message, never a newer one still queued.

* test(native-chat): the failed-start pins fail on what the message became, not on a timeout

* fix(native-chat): a restart offer ends when the chat's agent starts again

The offer used to end only when the chat's newest user message changed,
because opening a chat started its agent and that start could not be told
apart from real activity. Opening a chat starts nothing now, so the host
reads the fact it already publishes: a chat's status row goes from not
host-owned to host-owned exactly when its agent is started. At that edge the
offer and any failure record for the chat are withdrawn, unless the start is
a resume action's own (its continuation is the oldest undelivered message).

A continuation and a message racing to be first are decided at acceptance:
the continuation is refused, quietly and with nothing filed, when any other
message was accepted since the restart. A failed continuation start leaves
the offer retryable, and each resume action sends its own message id.

Deleted: the newest-user-message comparison, its journal reader, the
continuation filter, and the failure ledger's own "answered by the chat"
check. The marker still carries its message id for one release, so the
previous build can read it.

* fix(runtime): end a transcript stream when its client unsubscribes

Desktop: the IPC subscription controller was dropped as soon as the streaming
handler returned, which for most streams is right after it binds. A later
runtime:unsubscribe then found nothing to abort, so the host kept the subscriber
and derived and sent every publish to a channel no one listened to. The controller
now lives until the renderer unsubscribes, resubscribes the same id, or goes away.

Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe
with the stream's frame id, so the host ends that subscriber and leaves a sibling
stream on the same socket running. The direct path now passes the frame id the relay
path already passed.

* fix(native-chat): a late provider-session update keeps a failed recovery record failed

A provider-session heartbeat that rewrites a completed recovery record kept
its interrupted flag but dropped the outcome it was copied with, so a live
failed checkpoint read as a clean finish until the next status write.

* test(orchestration): the preamble's host stub is typed, not cast

The preamble send now takes only what it reads of the host, the send, the settlement wait and the
record's fence, so its test builds that host with real types instead of `as never`.

* test(native-chat): the terminal-bell check asserts the renamed verdict field

The bell notification test still checked for agentInterrupted, which no
longer exists, so it could not catch a verdict leaking into a bell dispatch.

* fix(native-chat): a failed turn ranks like a completion for attention

Attention readers (completion time, Smart Sort, sticky retention, Cmd+J
Recent) now demote only a turn the user stopped. A failure is news the
user has not seen, so it keeps its completion time, ranks in the Done
class, stays retained after its pane goes away, and a retained failure
reads failed in the worktree rollup instead of done. Clean-finish
policy (hibernation, pane ownership, the value moment) still treats a
failure like a stop.

The retention trigger compares verdicts again: success -> failure no
longer moves the completion clock.

* fix(native-chat): one fact ends a restart offer: the chat moved on since the restart

The offer is live while no other message has been accepted in the chat since the
restart and its agent has not proved a start since. The offer list, the resume's
reservation check and the continuation's acceptance check all read that one fact,
so a message whose start then failed withdraws the offer too, and a stale click
finds nothing to act on.

The fact is read off the conversation's open handle, which the restart closed, so
it is retired durably whenever it may have changed: a message accepted, a start
proven. A close and reopen within the same run therefore cannot bring the offer
back. A continuation rejected before it reached the agent does not count, so a
retry after a failed start still runs.

Deleted: the quit-time gate on withdrawal, which changed nothing because the
withdrawal and the quit's own offer write share one queue; the per-action
"withdrawn" flag and the separate acceptance check it paired with.

* test(native-chat): an older build reads the restart offer this build records

The offer lives in a file the previous release reads after a downgrade. Pin that
against the pinned release's own capsule, and run the lane when the marker or the
capsule changes.

* fix(native-chat): read a restart offer against where the journal stood when it was taken

"Since the restart" was read off the conversation's open handle, which the idle
sweep closes: after a reopen, a message the user had already sent looked older
than the handle and the withdrawn offer came back.

The offer now records the journal position (epoch and sequence) at the moment
it is taken, and a message accepted after that position, or a journal on another
epoch, means the chat moved on. That is derived from the journal, so it holds
across any number of closes and reopens. An older build's offer has no position;
only a start withdraws it. Because the message half is now durable, the offer is
no longer rewritten in the recovery file on every accepted message; a proven
start still writes it, since only the host that saw the start knows of it.

* test(native-chat): wait for the listing's retire write before reading the recovery file

* refactor(native-chat): every journal row states which turn it belongs to

Rows gain a turn scope stated by the write that creates them: the open root
turn, or the conversation. A queued message takes its scope from its handover.
Rows stored before scopes existed are placed on replay by the root turn open
when they were created, so no persisted state is needed for them. Rewind keeps
each retained row's scope and producer, so a subagent's row stays its own.

* fix(native-chat): keep the terminal-backed chat's read error over its local echoes

Messages winning over a read error is right for the structured chat, whose read retries and whose
messages came from the transcript. The terminal-backed view assembles its list from local echoes
too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no
error. Only the structured pane now keeps messages over an error.

* fix(native-chat): a start retries the exit settlement a failed journal write left owed

An agent exit whose journal settlement write failed releases the lease latched until a retry lands.
Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried
it before the next app launch, and every send was refused. The start the send needs now runs the
retry first, where the attach would.

* fix(native-chat): a failed main agent reads failed while its subagents still work

The verdict is now read from the main agent's own state, not the folded
row: a main agent that is done and failed has a verdict even while its
subagents keep the row working. Without mainAgent (history, worktree ps,
older hosts) the old combined-done rule stands.

Display marks the verdict through agentVerdictDisplayMark: a failure
outranks every combined state on the agent's dot, label, tab badge,
dashboard and activity rows; a stop marks only a done row, so a
successful or stopped main agent with live subagents still reads
working. Subagent rows keep their own state. The worktree card, terminal
tab and Cmd+J rollups share one pane fold and rank a pending question,
then failed, then working, monitoring, interrupted and done.

worktree ps publishes the main agent's outcome on a working row, and the
mobile mirror reads it. The store's change check, the paired-client
mirror's equality and its epoch now see a verdict change on a working
row, which otherwise moves no state or clock and left the worktree card
reading working. Clean-finish policy is unchanged: a working row is never
hibernated and has no completion time.

* perf(native-chat): answer the owner check without opening the chat

Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the
answer comes from the session record alone. Reaching it through the accessor opened each resting
chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it
open for the idle window. It now checks the record and the adapter's support, as before this series,
and opens nothing.

* fix(native-chat): a read waiting on the session lock opens nothing once quit began

The accessor checked for quit before queueing the open, so a read queued behind a session task ran
its open after teardown had begun and indexed a journal no teardown step would close. The check now
runs at the open itself.

* test(native-chat): pin stated turn scopes, the upcast of unscoped rows, and rewind attribution

* fix(native-chat): /compact is a message the chat sends, run as a turn of its own

The conversation command RPC now accepts /compact into the queue like any
send and answers once it is handed over. The delivery loop opens the command's
own turn, starts the provider on it, and waits for the provider's end off the
session's queue, so messages typed meanwhile are held and delivered after it,
even when it fails. It settles by re-reading the journal: a child that died
meanwhile already wrote the verdict. Stop ends the command at once. The 180 s
completion window, the unconfirmed row and the recovery of an older build's
compaction record are gone; that record no longer gates anything. On Codex the
provider turn the command opens is claimed into the command's turn.

* fix(native-chat): read a failed resume's chat before calling it retryable

Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the
restart, read from its journal. The failure list read it only for a chat already open, so once the
idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did
nothing. The list now opens the failed chats first, as the offer list does.

* test(native-chat): type the provider event sink the settlement test reaches for

* docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it

The field is new: an old host sends no outcome at all, so a reader falls
back to interrupted. The removed clause said old hosts send it on done
rows, which never shipped.

* fix(native-chat): say the structured read keeps trying only where it does

The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an
untranslated fallback whenever the read error had no text, and the empty state prefers any message.
The view state now leaves the message out, so the structured pane shows that line and the
terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an
error frame, so it no longer makes the claim.

* fix(native-chat): rows group under the turn their record names, not the one above them

Each row's turn is the turn its stated scope names, anchored on the entry
that opened it, or on the turn itself when the provider opened it unasked.
So /compact groups its own rows and the previous turn is untouched, a message
typed into a running turn joins it, and a provider-resumed turn folds under
its own Worked-for. A row reporting how a turn ended, an error or the
compaction separator, never folds. Desktop and mobile read the same keys; a
host that states no scope keeps today's positional grouping.

* test(native-chat): await the send's settlement instead of polling for the start

The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a
loaded machine outran. They now await the host's own settlement of the message.

* docs(native-chat): the status-store listing rule names provider-journaled user messages

* fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations

The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than
a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now
dated by the resume action.

Telling a rejected continuation from the user's own message read the operation ledger, whose rows
expire after about a day; after that a failed resume stopped being retryable. The offer now
records the continuation each action sends on its own capsule entry, bounded to the newest 16, so
the ids end with the offer. The ledger read is deleted.

* fix(native-chat): a /compact is not a request the sidebar, notifications or restart resume report

The sidebar's prompt, preview, verdict and instant, the turn-completion feed,
and the restart-resume marker read past a conversation command and its turn to
the last real request, so a /compact neither notifies nor re-dates the row,
and a command in flight is never offered as work to resume. An older client
shown a command's turn in the legacy form names the session's own agent.

* fix(orchestration): route no mail to a structured worker its orchestration released

A structured worker is routed on ownership, and a resting worker's lease is released, so ownership
held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found
at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one
restarted its agent. Routing now also reads the orchestration's own resource row: once it is
released, direct mail, group addressing and worker-show's addressable answer drop the worker, as
they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored.

* fix(native-chat): a failed retry names the user's prompt, not Orca's continuation

A resume's continuation is written to the chat before its start, so after a failed attempt the chat's
newest user message is that rejected continuation. A second failure then showed Orca's own restart
text as the chat's prompt. A retry now keeps the prompt its first failure named.

* test(native-chat): pin what a conversation command's admission refuses at rest and at handover

* test(native-chat): tests merged from the base state which turn their rows belong to

* fix(native-chat): a refused send notifies failed through the completion feed

The host's completion feed followed only the newest turn, so a send the
agent or its start refused, which creates no turn, read Failed on its row
but sent no notification. The feed now follows the session's latest
request, read from the projection the status feed already makes for the
commit: a turn keeps its id, a refused send is named by its journal item
key. It announces only while the session is idle, as the row reports a
verdict, so queued sends refused one commit at a time notify once, and a
withdrawn send falls back to a request already announced.

* fix(orchestration): read the released row optionally, as the authority does

worker-show's observation called the row lookup directly, which a runtime double without it threw on
and failed the structured tab-retirement release.

* chore(native-chat): one import per module and no unexplained casts in the turn-scope changes

* test(claude): pin which turn a Claude row joins, including a subagent's after the turn ends

* fix(native-chat): the status bar drops a restart offer the chat moved on from

The renderer re-read the host's restart offer only when a failed chat showed activity, so after a
message withdrew a pending offer the host answered no chats while the status bar kept counting one,
and clicking it opened nothing. The same watch now covers pending offers: a status change in an
offered chat asks the host again, once.

* fix(native-chat): a refused steer is read from the turn its handover named

The latest-request reader decided whether a refused send had joined a running turn by comparing
host clocks: its handover time against the previous turn's end. The handover row now states the
turn it delivered into, so the reader reads that instead and the clock comparison goes. A journal
written before handover rows stated a turn is scoped on replay from the turn open when each row
was written, which can differ from the clock reading only when a send and a turn's end share a
millisecond.

* fix(mobile): the native-chat controller contract carries the turn journal

The controller and overlay already pass nativeChatTurnJournal, but the
contract type never declared it, so mobile failed to typecheck.

* fix(native-chat): the live turn is the running turn, not the newest user row

A turn the provider opened on its own (a background wake, a resumed turn)
anchors on its own record, but the list still treated the newest user row
as the live turn. While such a turn ran, the settled user turn before it
lost its duration and the running turn's own rows were drawn as settled,
so its tool calls lost their live state.

nativeChatTurnMembership now answers both questions from the turn record:
each row's turn, and the live turn (the running root turn's anchor, else
the newest user row, which is also all an unscoped host has). Desktop and
mobile key liveness, the timing clock and the live status's row on it.

* test(native-chat): a turn the provider opened keeps its own clock

Pins that the local turn clock follows the live turn, so a wake after a
settled turn does not restart that turn's clock when no host durations
are recorded.

* fix(native-chat): a running turn no message opened draws its status on no row

Its live status belongs to the transcript-tail indicator alone. Once it
settles, its duration draws at its first row as before; a running turn a
message opened still draws on that message.

* fix(native-chat): every copy of a row carries the main agent's own status

History entries, sleep records and `worktree ps` rows carried a flattened
top-level `outcome`, copied under different gates and without the main agent's
clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type
the live row already persists and sends, and every copy site takes it with
`interrupted` through one function, `agentVerdictFields`.

- The accessor reads `mainAgent` then the legacy flag; the mobile mirror
  matches it line for line.
- Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a
  malformed value drops the field, never the record.
- Mobile dates a main agent that failed under live subagents by its own clock,
  as desktop does, and its row equality compares `mainAgent`.
- The activity feed reads a history entry's own `mainAgent` instead of
  rebuilding one; the sync key and history equality compare it.

* test(native-chat): pin the worktree ps verdict across host and phone versions

Pairs the real v1.4.212 host and phone row reader with this build: an old phone
reads a new host's rows by `interrupted`, a new phone reads an old host's rows
(no `mainAgent`) the same way, and a new phone reads a failure under live
subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout
now carries the phone's self-contained row reader, and the lane runs when the
`worktree ps` row producers change.

* test(mobile): name the parity table's row for its role

* test(native-chat): a roster of idle or finished children does not keep an agent awake

The sweep reads owed background work through the shared child-work liveness that upstream's
release clock adopted; a child that went idle or finished is not work the agent still owes.

* fix(native-chat): a request that settles while the user is asked something notifies once

The completion edge waited for an idle session, and a pending prompt (including a
subagent's approval) is not idle. Structured chat has no other attention producer,
so a main turn that finished while a subagent waited on the user sent nothing
until the prompt was answered.

The edge now waits only on owed work (a running turn or an unanswered send), which
the projection reports even beneath a pending prompt. A request that settles with
a prompt pending announces once; the renderer words it "needs input" from the
host status mirror's `attention`, and answering the prompt keeps the same request
identity, so it does not announce again. The wire shape is unchanged.

* fix(orchestration): a task dispatched into a resting structured worker keeps it running

The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal
resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a
dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while
that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's
process incarnation now counts, derived from the existing rows.

* fix(native-chat): a command's wait ends when its child does

The delivery loop waited for a /compact only on the adapter's compaction
tracker, which learns of the child's end only on some exit paths: a Codex
exit or close, and a Claude close, never reach it. The wait then never
ended, so nothing queued behind the command was delivered again, Stop had
no child to answer through, and the tracker's leftover entry refused the
next /compact.

Every way a child ends passes endProviderChild, so the host now offers a
per-child end signal there. The loop races the tracker against it (the
dead-generation settlement has already written the command's verdict),
and on that end asks every adapter to release the command, so a later
command runs and no later provider turn is claimed into the dead one.
The adapters' own exit-time releases were unreachable (Codex) or covered
one path of several (Claude), and are removed.

The Codex RPC test harness moves to its own module so the exit can be
driven through the real adapter's connection callback.

* fix(native-chat): keep refusing sends during a command on an older host

An older host's controller still refuses a send while a conversation
command runs, so dropping the client's block turned every message typed
during /compact into a 'not sent' row with Retry there. The block stays
for hosts that do not run the command as a send-path turn, and goes only
for those that do.

The signal is one the client already holds: a host that runs /compact on
the send path states a turn scope on every journal row it writes, the
same fact turn membership uses to tell it from an older host. Both now
read it from one predicate. On an empty conversation, or one whose rows
all predate the upgrade, the signal is absent until the command's own
entry streams in, so that brief window keeps the old local refusal; no
capability or wire field is added.

* docs(native-chat): comments stop describing the hold this PR removed

Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that
pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive
subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it.
Comment-only.

* fix(native-chat): the completion says when the user is being asked

A request that settles while a prompt waits on the user was worded "needs input"
from the renderer's status-feed mirror. Remote clients receive the status and
completion streams over separate sockets, so they can arrive in either order and
the wording could be wrong both ways.

The host already knows at emit time, so the completion now carries an optional
`awaitingUser: true` in that case and omits it otherwise. The renderer words the
notification from that field alone and no longer reads the status mirror. Old
clients ignore the field and word by outcome; old hosts never send it.

* fix(native-chat): a restart offer keeps the start its own continuation made

Whose start ended an offer was decided at read time, from whether the offer's continuation was
still the queued message. Once the provider refused that continuation, the child it had started
read as someone else's start, so the offer ended and its failure showed no Retry. The delivery
loop now records which queued message a start is for on the in-memory child, and the child's end
carries it; the offer counts a start as its own when that message is one of its continuations.

* fix(native-chat): a rewound turn still names the message that opened it

A Codex rewind rebuilds the epoch without submissions, so each sent message survives only under
its provider key. The kept turn records still named the submission key, so each turn anchored on
itself and its rows grouped apart from the message that opened it. The rewind now renames the
turn's opener along with the message.

* fix(native-chat): Stop ends only the command it names

Stop on a command turn abandoned whatever compaction the session had pending, so a late Stop for
an earlier /compact cancelled the one running now. The tracker now ends a command only when the
Stop names its turn, and the cancel reply reports whether it did.

* fix(native-chat): an agent gets a full idle window after its owed work ends

The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or
dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can
read done before the lead's wake-up turn writes anything, and stopping in that gap loses the
wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full
window afterwards, as the release clock it replaced did.

* test(claude): the options-read fixture runs a live child

The fixture marked its conversation running with a hasProviderChild field the
session type does not have, so the read took the at-rest path and refused a
session with no record. It now carries a child, which is what the read checks.

* test(native-chat): host tests reach its collaborators through a typed seam

The rest-test rig and three test files read the host's private members with
Reflect.get and cast the result. The host now exposes one test-only accessor,
collaboratorsForTests(), and the subscribers class a subscriberCountForTests()
beside its existing retainedActivityCountForTests(), so the tests are checked
against the real types and the casts are gone.

* fix(worktree-status): a departed agent's failure yields to live work on the worktree card

A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome.

* refactor(orchestration): one owner answers a structured worker's custody

Routing, group addressing, worker-show and the idle sweep each composed their own reading of
whether orchestration still holds a structured worker, so each new obligation or retirement state
had to be added to every reader. structured-worker-custody now derives both answers from the
worker-terminal list state coordinators see in worker-list: addressable is owned and not released,
and owed work is an active custody or an unsettled task dispatched to the same incarnation. The
owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup
already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests.

* refactor(orchestration): owed work is an open dispatch on the worker's incarnation

A supervised worker's own dispatch context stays open exactly while the worker is active, so the
separate active-custody branch only repeated it. Owed work is now one fact, which also states the
policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are
written once at the top of the module.

* docs(agent-status): a departed agent's failure ranks below live work on the worktree card

* fix(native-chat): a restart offer knows its continuations by a tag in their id

The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running
action's id in memory. Both could disagree with the journal: past the cap an old rejected
continuation read as the chat moving on, and a crash during a retry restored the failure's older
entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer
(its teardown and chat), then the action's own part, so any continuation of this offer, queued or
rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and
the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own
continuation the start was for, read against the stored marker.

* test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait

The tui-idle probe reads through readTerminal, which now awaits the structured
worker check before the PTY read, so the probe's snapshot request starts a
microtask later. vi.waitFor missed it on its first check and polled again at
50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot
then resolved after the wait had already timed out, so the test passed without
judging it, and the rejection landed before any handler was attached. Vitest
reported that as an unhandled error and failed the shard.

Polling every 1 ms sees the request within a few ms, so the snapshot is judged
while the wait is still pending.

* fix(native-chat): a message held behind /compact is drawn where it was handed over

A message typed while /compact runs was drawn above the compaction's result, between
itself and its own answer. The reducer kept every item at the sequence and timestamp of
the row that created it, and a queued message is created at acceptance, long before the
command it waits behind writes its result. The phone orders by that sequence and the
desktop by that timestamp, so both put the message first.

A queued message now takes its position from its handover row, the same row that already
states its turn scope. Everything the agent did before the handover, a command it waited
behind included, draws above it. This holds for every held message, not only /compact's,
and needs no client change: every client, older builds included, reads the position the
host publishes. A live batch already carries the item when its dispatch row lands, and
history pages cut the reduced timeline by sequence, so paging stays contiguous.

* fix(native-chat): a phone's send during /compact answers without waiting out the compaction

A client that predates accepted-send replies, which is every phone build, has its send
reply held until the host hands the message over. A message sent during /compact is not
handed over until the compaction ends, so the phone's 15 s request timeout fired first
and showed the message as unconfirmed.

That wait now also ends once the message is queued behind a running command. This is
read from the journal's running turn and needs no new state. Every other wait still
ends at the handover: behind a starting child or an ordinary turn, and for restart
resume, the command front door and orchestration, which keep the plain handover point.

* perf(native-chat): a rewind places provider items with one pass over the merged rows

A Codex rewind gives each provider item the old epoch never held the turn record for its
provider turn. It found that record by scanning every merged row, restoring each row's
body, once per provider item. That is quadratic, and it runs on the host's main thread
up to the journal's 10,000-row cap, twice per rewind. A rewind record written before
rows carried their scope holds no scope for any provider item, so it paid the full cost.

The merge now indexes turn records by provider turn id once, keeping the first match as
the scan did, and each provider item looks its record up.

* fix(native-chat): a view never restarts a chat whose last start failed

A Claude chat whose CLI exits during startup left one red row per start, and
every time a view bound to it (the chat opening right after its create died,
or the user switching back to it) the hold started the CLI again, so the same
launch-failure row repeated. Only a send retries a failed start now, the same
rule provider-exit recovery already applied; the rule lives in one predicate
the hold, exit recovery and the delivery loop share.

* fix(native-chat): a message waiting behind /compact is drawn after it until it is sent

A message sent while /compact runs is placed where it was handed over. It was still
drawn where it was accepted until then. /compact writes its result one step before the
handover, so for that step the waiting message sat above the compaction's separator.

A message the host accepted but has not handed over is not part of the conversation
yet, so both clients now draw it after everything the agent has done. The shared
projection moves it to the end, which is the order the phone draws. The desktop ranks
it with the other not-yet-sent rows, after the streaming preview. At handover it takes
its place from its handover row, which is also after the separator, so it never
appears above the compaction it waited for.

* fix(native-chat): the idle sweep reads owed work every tick

Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it
refreshed the clock at most once a window. Work that ended just before the next read left the
agent to be stopped at that read, moments after the work ended, which is the gap the refresh was
meant to cover. The sweep now reads owed work on every tick for a started agent, so the window
always runs from the last tick that saw work owed.

* fix(native-chat): a continuation handed to the agent stays sent

The offer read its own continuation as not reaching the agent while its dispatch was pending, which
also covered one already handed over and still unanswered. When the wait for that answer ended first,
the failure it filed read as retryable, and a retry sent a second continuation to an agent that may
have acted on the first. Only a continuation still queued, or rejected, is now read as unsent.

* test(native-chat): start the child the loop waits on with an attach, not a second view

A view no longer starts a child whose last start failed, so the R2 case that
waits on a child started since the failure now gets that child from a client
attach, the one non-send starter left.

* fix(native-chat): settle a gone generation's turn wherever a conversation opens

A send that opens a chat this process had not read yet (after a crash, from a
phone or the CLI) went through the delivery open, which never settled what the
dead generation left running; only the read restore and a successful acquire
did. When the send's start then failed, the turn stayed running for every
reader. The settlement now runs in the one journal open, at the crash boundary,
for every opener except an acquisition, which settles from the evidence it read
before its reserve; the read restore's separate step is gone.

* test(native-chat): prove the next child's start settles the turn an earlier child left

The R1 case lost its only settlement assertion when the latch it checked was
deleted. It now seeds the running turn the earlier child left and asserts it
ends at the exit's receipt, with the exit's row, before the message is handed
to the new child.

* test(native-chat): count a failed start's rows by row, not by text

Comparing the set of texts passed when two different rows carried the same
words, which is the duplicate the test exists to catch.

* test(cross-version): load the phone row readers without mobile's toolchain

Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json
extends expo/tsconfig.base.json, which the root-only cross-version lane never
installs. The worktree ps verdict suite imported the current phone row reader
from mobile/ directly, so CI failed with TSConfckParseError before any test ran.

The harness now imports a copy of the working-tree reader placed under the
checkout cache, where the root tsconfig applies, as it already does for the
release checkout's copy. Both readers are still the real files.

* test(cross-version): keep the checkout path-guard message and justify the copy import's cast

* fix(native-chat): a command ends only by its own provider answer or its child's end

Stop no longer settles a conversation command. It interrupts it like any turn,
and when the provider cannot take that (Codex has not opened the command's turn
yet, or Claude refuses the interrupt) it stops the child, whose dead-generation
settlement writes the verdict.

The pending command now lives on the provider child's own session instead of an
adapter-wide map keyed by session, so it dies with the child and nothing has to
release it. Claude's /compact is sent under a uuid the slot records, and only a
root result naming that input (or naming none) ends it; its outcome is read with
the ordinary result reading, so a stopped /compact is a cancellation.

* fix(native-chat): a command's settle answers its message before ending its turn

The two writes are not one batch. Writing the message's answer first means a
crash between them leaves a running command turn, which the stale-turn sweep
already settles, instead of an ended turn whose message reads as in flight
forever. The settle now writes only while the command turn is still running.

* fix(native-chat): "Worked for" counts from the handover, not the send

A message held behind /compact, or behind a cold start, used to count the wait
as the agent's work, although its row is drawn at the handover. Every handed-over
submission's turn, the command's own included, now starts at the handover row's
instant, falling back to the send time for a host that recorded none.

* test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget

* test(native-chat): the interrupted create's own retry continues again

The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its
own operation id, with a fresh start whose result nothing read. That fresh start passes with the
released-reservation continuation deleted, so the case the fix exists for went untested. The retry
and its assertion are main's again.

* docs(native-chat): three comments that still had views starting agents

A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction
left alone would refuse every send, so no agent would ever start to finish it; and a current host
raises the unattached read refusal only once quit began, with the attach window belonging to an older
host.

* test(native-chat): pin the open's and the send's start and row counts, however the view binds

Opening a fresh chat whose starts fail makes one start and one row, with two
views bound before or after the create's child died; one send makes one more
of each.

* fix(native-chat): a second Stop on a command ends its child; one compaction verdict for every provider

A Stop's note now names itself in its key, so a later Stop on a command still
running reads, from the journal, that the provider was already asked and never
answered, and stops the child instead of interrupting again. Nothing is held in
memory for it.

Adds the rule both translators will read a compaction's end by: only a
compaction the provider reported is a success; none after Orca's interrupt is a
cancellation; anything else is a failure. A real Claude capture, pinned as a
fixture, is why: a stopped /compact ends in the same success result as a
finished one.

* test(native-chat): a reader's open settles the turn a failed exit settlement left running

An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now
settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle
sweep closed, and a read that opens the chat before the restart restore reaches it.

* test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner

A subscription reads the conversation before it returns, so under load the two views took longer
than the create child's 300 ms start, which then exited before the test checked that it had not.
The child now takes a second to fail.

* fix(native-chat): settle a gone generation's turn at every open but an acquisition's

The journal open skipped the settlement whenever the lease read reserved or
live, to leave an acquisition's own open to the acquisition. But a lease a
crashed process left in recovery also reads live, until the next acquire
resolves it. A send that opened such a chat, from a phone or the CLI after a
crash on a host that could not prove the old owner gone, skipped the
settlement; when its start then failed, the dead turn stayed running for every
reader. The acquisition now says it is the opener, and every other open
settles, whatever the lease still claims.

* test(native-chat): hold the create's start open until the views bind

The "view binds while the create is still starting" case gave the create a
300 ms head start and asserted the views bound before it died. On a loaded
runner the holds took longer, the create's exit landed first, and the case
failed its own precondition. The create's initialize now waits on a gate the
test releases once the views are bound.

* refactor(native-chat): the provider's translator ends a command's turn; the loop holds no command state

A conversation command is now a turn of the provider child's own journal
pipeline. The adapter-wide tracker, its promise and the loop's settle step are
gone.

- Codex: the translator claims the provider turn that carries the command, scopes
  its rows to the command's turn, and writes the command's end in the same batch
  that settles that turn. Codex's own compaction marker is the success row.
- Claude: the command's turn is the translator's open turn until the result that
  answers the /compact input ends it. The command's own frames, such as the
  continuation summary, its echo and "Compaction canceled.", draw nothing.
- Both read the end with the one compaction rule: success needs the provider's
  report of the compaction; none after Orca's interrupt is a cancellation.
- The message resolves at the provider's receipt, as any send does: the Codex
  ack, or the Claude slash-command waiter on its result. The host writes a
  command's end only when the provider never took it.
- The delivery loop stops while a command's turn runs, and every journal commit
  re-wakes it through the session's serialize, so an end that lands while a step
  decides to stop is never lost. A child that ends first is settled with it.

* test(native-chat): pin a command's end to real /compact frames and to each path it threads

The captured /compact frames drive the Claude translator's command turn: a
finished compaction ends as a success with only the separator drawn; a stopped
one ends as a cancellation with no failure row, and the next send answers in its
own turn; a result naming another input ends nothing. The command's end is
checked at each point the ordinary result path threads through: the reopen latch
after a failure, the settling of a child still working, the context facts the
result reports, and the provider's own error row.

On the host: a message held behind a command is handed over when the command
ends just as the loop stops for it, a refused command settles as a failure and
the loop moves on, and a Claude child that exits mid-command settles the command
and hands what waited to a fresh child.

* test(native-chat): tests merged from the base state which turn their rows belong to

* refactor(native-chat): drop the child-end waiter nothing waits on

A command no longer waits for its child here: its turn ends from the provider's frames or from
that child's settlement, and the delivery loop is woken by the commit. The waiter and its test
were left from the earlier shape.

* fix(native-chat): a command holds the queue only while its child runs it

The delivery loop stopped whenever the journal showed a command's turn running. When the
command's child ended and its settlement could not be written, that turn stayed running with
no child to end it, and the loop's gate kept it from ever starting the next child, which is
what settles a gone generation's leftovers. Every later send was held for good, and Stop had
no child to end.

The gate now holds only while the conversation has a child: with none, the command belongs to
a gone generation, and the loop's start settles it like any turn a dead child left running.

* fix(native-chat): a Claude /compact succeeds only on its compaction boundary

The command's evidence counted Claude's `compact_result: 'success'` status as the compaction
done. That status comes before the boundary that replaces the history, so a Stop landing
between the two read as a finished compaction even though no boundary was ever written. Only
the boundary now counts, as the rule for both providers states; the capture's finished
compaction carries one, so it still reads as a success.

* fix(native-chat): a Claude child's exit says why the turn it ended stopped

When a Claude child exited mid-/compact, the command showed "Worked for 0s" and no reason. The
child's translator ends its open turn the moment the exit is reported, stamped with the exit's
instant, so by the time the exit settlement ran nothing was running. The settlement recognises a
turn the exit already ended by that same instant, but the Claude lifecycle event dropped it on the
way to the host, which then used its own clock, matched nothing, and wrote no row. When the clocks
did agree, the row was scoped to the running turn, of which there was none, so it landed outside
the turn it explained.

The exit's instant now reaches the host, and the exit row belongs to the turn the exit ended:
still running, or ended by the translator at that instant.

* fix(native-chat): a message waiting behind /compact draws below its live activity

A message sent while /compact runs waits on the host until the command ends. Both clients moved
it to the end of the transcript rows, but the running turn's live activity line ("Compacting the
conversation") draws after every row, so the waiting message sat between the command and its own
live status.

A row that is queued, and not what the live turn is for, now draws after that live activity: on
desktop outside the transcript window, below the activity line; on the phone in the list footer,
below the live status. A message whose own start is pending still draws above the activity that
start reports.

* fix(native-chat): only a running command holds a message below its live activity

A message is accepted, then handed over a moment later, and in between it reads as waiting. Every
message waiting behind a live tur…
…agent does, so no stray effort picker appears (stablyai#24267)

* fix(native-chat): a resting chat lists its current model as a live child does, with no phantom effort

A chat at rest (no live child) answered its options from the host's model
catalog alone. When the chat's model is one the catalog does not list, the
answer left it out, the client filled the gap from its static seed, and the
composer showed an effort control ("Medium") that the live child never offers;
the control vanished again once the child was back.

The live Claude and Codex answers and the resting answer now build their model
list through one function: the catalog's rows, plus the current model when the
catalog does not list it, with no effort levels. Live and rest can no longer
disagree about it.

* fix(native-chat): a resting chat with no catalog yet lists what a live child would, so a listed model keeps its effort

When the host has no model catalog for the account yet (the first read before
its probe returns, a failing probe, an account-home lookup that failed, or a
WSL-pinned chat no live child has written one for), the resting answer listed
only the saved model, as unlisted, and a listed model such as opus lost its
effort control. It now rests on the list a running child falls back to:
Claude's built-in models, then the shared function. Codex lists nothing
without its catalog, so the resting answer leaves the list to the client's own
defaults, as a failed live read does.

* fix(native-chat): a resting chat with no pick and no catalog names no model, as before

With no catalog, the built-in list's default model is a guess, not the
account's: naming it moved a chat the running agent reported on Opus to
Sonnet. Only a real listing names the default now; otherwise the answer names
none and the client keeps the model the agent last reported. The comment on
Codex's no-catalog answer now says what it does: the client fills the current
model from its own defaults, unchanged from before.
…tablyai#24088)

* feat(persistence): run profile backups in the worker whenever its entry is bundled

* refactor(orcad): make profile and native preflight runtime-neutral

The profile preflight parser now takes the expected runtime identity from the
caller (shipped callers pass the pinned Bun identity), and the native
preflight is renamed to orcad-runtime-native-preflight with neutral wording.

* test(persistence): skip plain-Node backup selection tests in the Bun profile suite

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…ase (stablyai#24089)

Lands daemon-protocol-facts.mjs from the Windows update diagnostic branch with a
stricter parser, and adds check-daemon-protocol-crossing.mjs (rule R1): the working
tree must attach the newest release tag's daemon. Rollback crossing is reported only.
Runs in the cross-version-wire job, which already has full tags; tag selection moves
to config/scripts/stable-release-tags.mjs so both use one rule.

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…own (stablyai#24071)

The test re-arms a setImmediate tick while the copy runs and stopped it with a
flag. A tick already queued when the import resolved still ran, and when the
afterEach teardown closed the database first it threw journal_closed as an
unhandled error, failing a CI shard with every test green. Clear the queued
tick instead.
…es the conversation (stablyai#24235)

* fix(claude): a Stop ends Claude's process, and the next message resumes the conversation

Claude's Stop now ends its child after the interrupt, whatever Claude answered, once the stopped turn ends or a 3 s grace runs out. The grace lets Claude's own result move the resume point past that turn, so the next send resumes after it. Background work ends with the child. Codex keeps its child.

* test(native-chat): the router answers stopEndsSession for the session's live owner

* test(native-chat): type the Stop test's close mock as the adapter's optional method

* fix(native-chat): a Claude Stop answers on its interrupt, and the next step on the chat's lane ends the child

The Stop now answers as soon as the interrupt step finishes. Ending Claude's
process runs as a second task on the session's serialized lane, queued in the
same tick as the Stop, so a message sent meanwhile reaches only the resumed
child. That step waits for the stopped turn to end (its result, the CLI's idle,
the child's exit or its close), then ends the child; a failure there is
reported and never fails the Stop, and the wind-down it leaves owed is retried.

The interrupt's answer and the turn's end share one 3 s grace counted from when
the interrupt goes out, so an interrupt Claude never answers ends the child in
about 3 s instead of after the 30 s control deadline. The wait is derived per
call from Claude's open turn: the armed latch and the close's user-stop branch
are gone, and the close keeps its whole deadline for proving the exit.

A Stop naming a turn that has since ended (the phone names the turn it last
saw) now ends the child too when Claude took it, as it does when Claude
interrupts a handed-over follow-up whose turn has not opened.

* test(native-chat): pin a Claude Stop's race, failure and timing cases on the shipping adapter

- Nothing queued while the Stop's first step waits on the interrupt runs before
  the child ends.
- A message sent with the pre-Stop fence during the Stop is accepted with no
  notice, and reaches only the resumed child, after the old child's close.
- An interrupt Claude never answers ends the child within the grace.
- A close that cannot prove the exit leaves the Stop answered with its row; the
  failure goes to the host's error hook.
- A Stop naming the turn that just ended ends the child when Claude interrupts
  the handed-over follow-up.
- A second Stop pressed while the first ends the child stays quiet.

* fix(native-chat): a Stop naming an ended turn ends Claude's child when its interrupt fails or goes unanswered

The phone names the turn it last saw. When Claude interrupted a handed-over
follow-up for that Stop and the answer failed or never came, the child stayed.
Only a provider that answers it did not take the Stop keeps the child now.
Also pins a queue-if-active send made while the Stop ends the child.

* fix(native-chat): a Claude card's Cancel denies an approval and stops on a question, never a bare interrupt

A permission card's Cancel interrupted the turn and kept Claude running, the
old Stop on a second control: a refused interrupt let the turn run on and
background work survived. Now the host asks the provider how a card's Cancel
is answered. For Claude, an approval's Cancel sends the same reply as its Deny
option, so the turn goes on without that tool; a question's Cancel runs the
chat's Stop, which ends the child, and the next send resumes the conversation.
A provider that gives no answer keeps today's path, so Codex is unchanged.

The host decides, so desktops and phones of any version keep sending the same
cancel and get the new behaviour; nothing on the wire changes.

With no Claude caller left, the prompt-cancel interrupt is gone: the bound
claim, the cancellation observation, the admission of the prompt's
cancellation, the prompt's turn binding, and withdrawing a refused stop (every
Claude interrupt now precedes the child's end, so the recorded stop stands).

* fix(native-chat): a Claude approval card's Stop option is the chat's Stop

The approval card's "Stop" option answered Claude with a deny that interrupts
the turn and kept Claude running, the last control on a Claude chat that
interrupted without ending the child. The host now routes it, like the card's
own Cancel, through the provider: for Claude that option is the chat's Stop.
It runs the same Stop body as the Stop button, in the same order (withdraw,
the Stop takes effect, interrupt), and the step that ends the child is queued
in the same synchronous call. The body now lives in one place,
structured-agent-session-chat-stop.ts, shared by the Stop button, a question
card's Cancel and this option.

The host decides, so an old card or client that sends option `cancel` gets the
real Stop; nothing on the wire changes. The interrupting deny reply is gone.

* test(native-chat): the option-route test's answer carries a whole journal resolution

* fix(native-chat): Claude cards drop their Stop option, and a plan card's Cancel asks Claude to wait

- Approval and plan cards no longer offer "Stop". Neither reference offers a
  Stop while an approval is up: the user denies, then stops. An older card's or
  client's `cancel` answer is a plain deny, and the respond path is main's again.
- A plan card's X / Esc no longer answers "Keep planning", which sent Claude
  straight back to revise and re-propose. It dismisses the plan: a deny that
  tells Claude to end its turn and wait for the user. No interrupt; the child
  stays. A tool approval's Cancel stays the Deny reply, and a question's Cancel
  stays the chat's Stop.
- The provider's routing takes the pending card itself
  (`routePromptCancel({ sessionId, prompt })`), so a plan is told from a tool
  approval, instead of a kind plus an optional option id that meant Cancel when
  absent. A routed answer may be one the card does not offer, like the
  dismissal.
- The chat's Stop is reachable only through `mutateWithChatStop`, which owns the
  mutation call and queues the step that ends the child in the same call, so
  no caller can run the Stop and forget the child's end.

* refactor(native-chat): cancelPlan no longer takes a stopChild nothing passes

The Stop's own body ends the child; the plan's default run serves only a card's
interrupt and the background-task stop, which never end it.

* fix(native-chat): a question card's Cancel settles the card in the Stop's first step, and answers Claude when nothing stops

A question card's Cancel runs the chat's Stop. The card stayed pending until
the child ended (up to about 3 s), so it could still be pressed and the second
press was refused once the chat rested. When the Stop found nothing in flight
(Claude asking after its main turn ended), the card stayed pending for good and
Claude's request went unanswered; the phone froze the card with every button
disabled.

Now the card is recorded as cancelled by the user in the Stop's own step, with
the receipt "Cancelled on <device>". A Stop that ends the session leaves
Claude's request to end with the child, so no reply races the interrupt; one
that ends nothing declines the request itself. The provider forgets the card
once the host records it, so neither Claude's own cancel nor the child's end
writes over the user's cancel. The card's Stop stops whatever the chat has in
flight, like the Stop button, instead of a turn the card names that may have
ended.

The same dismissal, a new provider member beside the cancel route, now answers
a plan card's Cancel: recorded as cancelled by the user, and Claude told to end
its turn and wait. That replaces resolving it as an option the card does not
offer.

* test(native-chat): the question-card Stop test has Claude cancel its held request, as it does once interrupted

* fix(native-chat): a Claude Stop before the echo reads Interrupted, and a question card's Stop stays in its own turn

- A Stop pressed after Send but before Claude echoed it read as a normal finish
  ("Worked for 0s", a green done check): with no turn row to wait on, the child
  was ended at once, and the echo that would have opened the stopped turn
  landed on a retired send. The wait before the child ends is now keyed on what
  Claude has in flight (an open turn or a send it has not answered), not on a
  journal turn id, within the same 3 s from the interrupt and woken by the same
  settles; a settle that leaves something in flight waits on. The echo opens
  the turn, the aborted result ends it Interrupted. A Claude that says nothing
  is ended when the grace runs out, the unanswered send doubt as before.
- A question card's Cancel takes the chat's Stop only when the card belongs to
  the turn running now, judged after draining the provider's lifecycle. A card
  a finished turn raised, such as a background agent's, is dismissed instead:
  Claude's request is declined and nothing stops.
- Claude forgets a dismissed card before the host records it, so Claude's own
  cancel landing during that write cannot replace the user's "Cancelled on
  <device>".

* test(native-chat): Stop tests read background work from the host's child records, and guard the optional dismissal

Main's child records replaced the adapter's background-task callback: the
background-work test now reads the host's child record (live before the Stop,
settled after the child ends) and the session's agent status (done, Interrupted,
nothing left for Monitoring).

* fix(native-chat): a tool card's Cancel reads Cancelled, and a Stop's background work reads stopped

- A tool approval card's X / Esc already sent Claude the plain deny, but the
  card read "Deny · Answered on <device>", the same as pressing Deny. It is
  now a dismissal like a plan card's: recorded as cancelled by the user, with
  the same deny reply, no interrupt and the child kept. Every card's Cancel
  now routes to a dismissal or the chat's Stop, so the option route is gone.
- When a Stop ended Claude's child, a background agent or shell it was running
  settled as an unknown ending (a neutral dot), while the same task's own stop
  reads Interrupted. A close Orca asks for (a Stop, a rest, a quit) now ends
  what still runs as stopped; only a session that dies on its own leaves the
  ending unknown. The close reads no stop cause. A rest never meets live child
  work: the idle sweep keeps a chat with any.
- If the host fails to record a dismissed card, Claude gets the card back, and
  a withdrawal Claude made meanwhile closes it, as before the host took it.

* refactor(native-chat): the session mutation path gets its own module, breaking the chat-stop import cycle

chat-stop.ts imported mutateStructuredAgentSession from host-mutations.ts,
which imports mutateWithChatStop back. The mutation context and the one
admit-then-serialize path now live in structured-agent-session-mutation-context.ts,
which both import; host-mutations re-exports the context type for its other
readers.

* fix(claude): a close that saw a descendant survive leaves background work's ending unknown

A Stop, rest or quit ended still-live background tasks as stopped even when the
close found Claude's root gone but a descendant still running. Only a close
that proved the whole tree gone stops them now; otherwise they settle unknown,
as for an exit of the session's own. The stopped ending is marked as Orca's,
so a frame of the child's own still replaces it.

* docs(native-chat): comments describe the Stop as it now works

What re-drives a failed child end, when the child-work decoder stops what is
live, what a Stop's wait re-reads on a result, and why a card's Stop is
unnamed.
…tablyai#24108)

* ci(daemon): gate PRs on daemon protocol crossing from the newest release

Lands daemon-protocol-facts.mjs from the Windows update diagnostic branch with a
stricter parser, and adds check-daemon-protocol-crossing.mjs (rule R1): the working
tree must attach the newest release tag's daemon. Rollback crossing is reported only.
Runs in the cross-version-wire job, which already has full tags; tag selection moves
to config/scripts/stable-release-tags.mjs so both use one rule.

* feat(persistence): run profile backups in the worker whenever its entry is bundled

* refactor(orcad): make profile and native preflight runtime-neutral

The profile preflight parser now takes the expected runtime identity from the
caller (shipped callers pass the pinned Bun identity), and the native
preflight is renamed to orcad-runtime-native-preflight with neutral wording.

* feat(runtime): pin the Node 24.21.0 server runtime with an offline CI check

Add src/shared/node-runtime-pin.ts (NODE_RUNTIME_PIN, SERVER_TARGETS,
NODE_RUNTIME_ASSETS for all 8 server targets plus the headers tarball),
generated by config/scripts/update-node-runtime-pin.mjs from the nodejs.org
and unofficial-builds SHASUMS. check-node-runtime-pin.mjs verifies, with no
network, that the pin tracks the locked Electron, matches engines.node's
major, and covers exactly SERVER_TARGETS; it runs in the static analysis job.

ORCAD_BUN_TARGETS consumers now read SERVER_TARGETS so there is one target
list; orcad's Bun runtime and build output are unchanged.

* test(persistence): skip plain-Node backup selection tests in the Bun profile suite

* fix(runtime): reject a pinned archive that belongs to another target

* ci(daemon): fail PRs that swap a runtime launcher and bump the daemon protocol

D7.1 R3: hosting orcad or the daemon on another runtime is not a protocol change,
so one PR must not do both. The launcher file list lives in the check script; the
allow-runtime-launcher-protocol-bump label overrides it.

* feat(orcad): select pinned-Node slots by a .runtime-node marker

D7.1 R5: a Node slot names its shared runtimes/node-<sha256>/node through
.runtime-node instead of .build-target, so Bun-era clients read it as a legacy
slot rather than exiting 78 on a missing bun-runtime. Nothing builds the marker yet.

* fix(runtime): load the Node pin without the typeless-module warning

check-node-runtime-pin.mjs now requires the pin and takes nodeDistArchiveName from
its own module, so it no longer loads the update script's build graph.

* fix(orcad): resolve Node slots to the design's runtimes/node-<sha>/bin/node layout

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
)

* ci(daemon): gate PRs on daemon protocol crossing from the newest release

Lands daemon-protocol-facts.mjs from the Windows update diagnostic branch with a
stricter parser, and adds check-daemon-protocol-crossing.mjs (rule R1): the working
tree must attach the newest release tag's daemon. Rollback crossing is reported only.
Runs in the cross-version-wire job, which already has full tags; tag selection moves
to config/scripts/stable-release-tags.mjs so both use one rule.

* feat(persistence): run profile backups in the worker whenever its entry is bundled

* refactor(orcad): make profile and native preflight runtime-neutral

The profile preflight parser now takes the expected runtime identity from the
caller (shipped callers pass the pinned Bun identity), and the native
preflight is renamed to orcad-runtime-native-preflight with neutral wording.

* feat(runtime): pin the Node 24.21.0 server runtime with an offline CI check

Add src/shared/node-runtime-pin.ts (NODE_RUNTIME_PIN, SERVER_TARGETS,
NODE_RUNTIME_ASSETS for all 8 server targets plus the headers tarball),
generated by config/scripts/update-node-runtime-pin.mjs from the nodejs.org
and unofficial-builds SHASUMS. check-node-runtime-pin.mjs verifies, with no
network, that the pin tracks the locked Electron, matches engines.node's
major, and covers exactly SERVER_TARGETS; it runs in the static analysis job.

ORCAD_BUN_TARGETS consumers now read SERVER_TARGETS so there is one target
list; orcad's Bun runtime and build output are unchanged.

* test(persistence): skip plain-Node backup selection tests in the Bun profile suite

* fix(runtime): reject a pinned archive that belongs to another target

* ci(daemon): fail PRs that swap a runtime launcher and bump the daemon protocol

D7.1 R3: hosting orcad or the daemon on another runtime is not a protocol change,
so one PR must not do both. The launcher file list lives in the check script; the
allow-runtime-launcher-protocol-bump label overrides it.

* feat(orcad): select pinned-Node slots by a .runtime-node marker

D7.1 R5: a Node slot names its shared runtimes/node-<sha256>/node through
.runtime-node instead of .build-target, so Bun-era clients read it as a legacy
slot rather than exiting 78 on a missing bun-runtime. Nothing builds the marker yet.

* fix(runtime): load the Node pin without the typeless-module warning

check-node-runtime-pin.mjs now requires the pin and takes nodeDistArchiveName from
its own module, so it no longer loads the update script's build graph.

* fix(orcad): resolve Node slots to the design's runtimes/node-<sha>/bin/node layout

* feat(orcad): 8-slot node-pty prebuilds against the pinned Node headers at N-API 8

- build-orcad-prebuilds.mjs adds win32-x64/arm64 (conpty.node, the vendored
  conpty.dll/OpenConsole.exe, upstream's N-API conpty_console_list.node), compiles
  in a scratch copy against the hash-verified pinned headers (node.lib pinned per
  Windows arch) with NAPI_VERSION=8, rejects post-8 node_api_* imports, and writes
  a schema 2 manifest with per-file sha256, N-API level and the glibc need.
- --require-slots [slots] verifies files against hashes; --smoke loads the slot
  under the pinned Node and spawns a PTY; --print-slot names the host slot.
- The slot installer gates on N-API, libc, arch, glibc and file hashes instead of
  the exact NODE_MODULE_VERSION, and installs nested files (conpty/).
- bun-profile-tests.yml builds, verifies and smokes each runner's slot.

* fix(orcad): scope node-pty's glibc .symver pins to glibc on musl prebuild slots

musl's unversioned libc cannot satisfy openpty@GLIBC_* references at link
time, so the Alpine slot compile would fail. Pin the staged pty.cc guard to
__GLIBC__ and assert both musl transforms against the installed patch.

* feat(orcad): run orcad on the pinned Node instead of Bun

A packaged orcad slot now references the pinned Node 24.21.0 by its
executableSha256 (`.runtime-node`, `.server-target`) instead of carrying
bun-runtime, and ships node-pty from the slot's prebuild, only its own
ripgrep, and no Windows Bun PTY gate. The runtime lives beside the slots
at runtimes/node-<sha>/bin/node (node.exe on Windows, upstream name).

- build:orcad (build-orcad-node.mjs) builds the host slot's prebuild when
  missing and places the pinned runtime; the template is schema 3 with
  per-target files.
- handoffToBundledOrcad() resolves the slot's runtime reference and checks
  process.versions.node against the pin; a host Node >= 18 still hands off.
  Startup preflight keys on running as that runtime; callers expect 'node'.
- orcad and its daemon use node-pty (ConPTY + windows-pty-job on Windows);
  the Bun PTY sources, gate entry and canUseBunPty branches are removed.
- SSH deploy uploads the official archive once per pin, extracts and
  hash-checks it on the host, and self-tests it before publishing. Bun
  slots stay launchable for rollback; Node slots never use host Node.
- The runtime materializer is generic over pinned assets; the Bun wrapper
  remains only for the OpenCode vault reader (design Phase 2).
- Cross-runtime test: a profile DB written by Bun 1.4.2 (WAL left by
  SIGKILL) opens and backs up under the pinned Node, and the reverse.

No daemon PROTOCOL_VERSION change (design D7.1 R3).

* docs(ci): name the headless lanes after the pinned Node, drop Bun shard timings

Design D10: ci-demand-rollout.md and ci-runner-efficiency.md follow the
bun-profile-tests.yml -> node-server-tests.yml rename; shard timings drop the
deleted Bun PTY tests and follow the renamed ones.

* chore(ci): count the runtime archive download as a runtime launcher path

* fix(orcad): pin the macOS C++ standard for node-pty prebuilds

The official Node headers' config.gypi sets clang: 0, so common.gypi skips its
gnu++20 xcode_settings and Apple clang 15 (macos-14 runners) compiles
node-addon-api as C++98.

* fix(orcad): resolve the preflight's slot through realpath, as the handoff does

A symlinked orcad.js handed off to its real slot's pinned Node, but the
startup and profile preflights read the symlink's directory, found no
runtime marker there, and silently skipped the readiness check.

* refactor(ssh): drop materializeCachedNodeRuntime, which nothing calls

Deploys upload the verified official archive (design D5); no client path
needs an extracted Node executable cached by digest.

* test(orcad): gate the Bun-to-Node upgrade and Node-to-Bun rollback with live terminals

Design D7.1 R1/R3/R4 and D7.2. The last Bun orcad and this checkout's Node
slot are installed side by side under ~/.orca-remote, launched and stopped
with the client's own deploy commands, and share one data root. Each
direction proves the incoming orcad adopts the outgoing runtime's daemon
(same PID, same shell, output continues), opens its profile database and
backs it up with its own shipped worker, and that GC keeps the slot the
live daemon was forked from.

The node-server Linux lanes provide Bun 1.4.2 and build that Bun orcad
from main, and run with --cross-runtime. --artifact and --cross-runtime
now make their tests fail on a missing input instead of skipping.

* ci(node-server): pin node:24.21.0-alpine by its multi-arch index digest

* test(ssh): name the runtime archive fixture after its role

* test(node-server): load node-pty from the packaged slot in artifact runs

The node-server lane installs dependencies without building node-pty, and
Linux has no upstream prebuild, so the real-PTY failed-I/O teardown test
(picked up by the pty-subprocess selector) could not load pty.node. In
--artifact runs, alias node-pty to out/orcad's shipped slot so the test
exercises the addon orcad actually runs under the pinned Node.

* fix(orcad): let the Windows profile preflight exit after its PTY probe

On Windows, node-pty keeps the conout worker thread and pseudoconsole alive
until kill(), even after the shell exits. The PTY health probe never killed a
cleanly exited probe, so the packaged preflight printed its readiness line
and then hung until the build's 30s timeout, reported with an empty stderr.

- The probe kills its PTY on Windows after exit and uses the bundled ConPTY
  the daemon spawns with.
- The preflight exits once stdout is flushed; its owner reads to EOF.
- Preflight failures now report code, signal, timeout, stdout and stderr.

* test(node-server): load the slot's node-pty in the real-PTY test, not by alias

A vite alias redirected only ESM imports of node-pty; windows-pty-job and
local-pty-utils resolve it through require, so Windows loaded two conpty.node
copies and the Git Bash job-membership proof read an empty job. The failed-I/O
teardown test now loads node-pty through a fixture that picks the packaged slot
in artifact lanes.

The pty-subprocess selector was a prefix that also pulled in its POSIX-host
sibling unit tests, which pr.yml runs and which were never qualified on
Windows. Select the directory plus the two sibling files that belong here.

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…ly (stablyai#24320)

* fix(relay): gate the Asia canary on region fallbacks against a pre-canary baseline

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): gate the Asia canary on region fallbacks against a pre-canary baseline

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
@innocarpe innocarpe added the bug Something isn't working label Oct 1, 2026
Jinwoo-H and others added 14 commits October 1, 2026 04:07
…promotion (stablyai#24318)

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
…move Bun (stablyai#24128)

* feat(ai-vault): read OpenCode history with the pinned Node instead of Bun

SSH hosts whose Node lacks node:sqlite (or its backup(), which 22.13-22.15
omit) now get the pinned Node in the shared ~/.orca-remote/runtimes/node-<sha>
store orcad uses: POSIX hosts receive the official archive and extract and
hash-verify it on the host; Windows hosts receive the verified node.exe the
client extracted, promoted by host Node with the same hash check. WSL distros
use the same layout and checks under ~/.cache/orca/runtimes/.

The Bun release pin table and its materializer are deleted. Old relays keep
reading their vault-sqlite/<sha>/bun references; nothing deletes those files.
An unconfirmed runtime upload now keeps its stage instead of removing it.

* refactor(sqlite): drop the Bun SQLite adapter; node:sqlite is the only backend

Nothing outside Electron runs on Bun any more (design D4), so SyncDatabase
loses its Bun branch, and bun-sqlite-database, bun-sqlite-statement and
bun-readonly-wal go, with the relay's bun:sqlite external. The profile-state
backup worker admits Electron or an entry that exists, and startup errors
name the pinned Node. The D7 cross-runtime gate still runs Bun 1.4.2, now
reaching Bun's SQLite through its node:sqlite.

* test(native-chat): drop the Bun SQLite driver case now that node:sqlite is the only backend

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
… store GC (stablyai#24130)

* fix(ssh): relay version GC deletes only on an exited verdict and keeps the previous build

The relay records .relay-pid in its version dir once it owns its socket. GC calls a
relay version dir exited only when that PID is provably dead and every relay-*.sock
refuses a connection; a dir without a PID file keeps the test -S rule. The most
recently completed other relay build is pinned like orcad's rollback target.
Design D5 GC liveness.

* feat(ssh): collect the shared runtimes/ Node store and give it its own owner

runtimes/ gets its own owner in the install model, so no version-dir GC (new or old
clients, whose listings are prefix-scoped) can list or delete it. A store pass
removes node-<sha> only when no retained dir references it, it is neither a current
pin nor the newest other verified runtime, and a ps or /proc check ran and found no
process using it. Legacy relay-*/orcad-* dirs are read for references and reported
as diagnostics only (design D10 two-step). Wired behind orcad GC's nodeRuntimePins.

* fix(ssh): runtime store process check holds runtimes reached through a symlinked home

/proc exe resolves symlinks and argv keeps whatever spelling launched the runtime, so
filtering on the exact $root path missed in-use runtimes on hosts like /home -> /var/home.
Filter on the store segment instead; the parser already attributes holds root-agnostically.

* test(ssh): wait for the holder process to spawn instead of a fixed delay

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…compat slot (stablyai#24134)

* build(orcad): build server glibc slots on glibc 2.28 and add the glibc 2.17 compat slot

Design D6: the default linux-{x64,arm64}-glibc node-pty slots now build in
manylinux_2_28 (digest-pinned) and are gated at glibc 2.28 / GLIBCXX_3.4.25
through a floor profile on verify-linux-glibc-floor.cjs; the desktop keeps
its Ubuntu 20.04 (2.31) default.

Adds the opt-in linux-x64-glibc217 compat target: NODE_RUNTIME_COMPAT_ASSETS
pins the unofficial glibc-217 Node (update/check pin scripts cover it, outside
SERVER_TARGETS), and a new CI lane builds the compat slot in manylinux2014
with static libstdc++, gates it at glibc 2.17 with no shared C++ runtime in
DT_NEEDED, and smokes it under the glibc-217 Node.

* refactor(node-runtime-pin): route compat lookups through isCompatServerTarget; keep the glibc doc's slot-name paragraph intact

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…tablyai#24129)

* feat(relay): runtime self-test flag and informational runtime on handshake-ok

relay.js --orca-runtime-selftest <nonce> dlopens pty.node, opens and closes a
PTY, and prints one JSON line (nonce, node, napi, glibcVersionRuntime) for the
client to classify before it launches a daemon on a runtime (design D5).

handshake-ok gains an optional runtime {kind, version}; bridge and daemon
already match exactly on version, so it is informational only (D8.1).

* feat(ssh): opt-in pinned-Node relay with prebuilt addons (D5, D6 rung A, D8.1)

SshTarget.remoteRuntime (legacy | pinned-node, default legacy; env
ORCA_SSH_REMOTE_RUNTIME for development) selects the runtime. On POSIX hosts
the pinned path resolves the target with its glibc major.minor, ensures
~/.orca-remote/runtimes/node-<sha>/bin/node, uploads the relay bundle plus
the target's node-pty slot and @parcel/watcher from the orcad artifact
(no npm or node-gyp on the host), writes .runtime-ref-node-<sha>, and folds
the runtime and addon digests into the relay version so pinned and host-Node
builds never share a dir or socket.

A 30 s self-test (node --version, then the relay self-test) gates
.install-complete. Timeouts and lost channels are unverifiable and never step
down; noexec, missing_lib, libc_floor, illegal_instruction and wrong_libc
refusals fall back to the untouched host-Node path with a logged reason,
remembered for the session.

* fix(ssh): only an answered libc probe steps the pinned relay down

A lost channel during target detection says nothing about the host; descending
would launch a host-Node daemon beside a running pinned one and strand its sessions.

* test(ssh): mark the mocked SSH connection casts in the pinned relay tests

* test(ssh): resolve the pinned runtime mock to an executable path

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…-agent-session-adapter.ts so main's lint passes (stablyai#24323)

structured-agent-session-adapter.ts reached 301 lines once two changes on
main met, one over the 300-line limit, so oxlint fails on main.

The file is the adapter contract: its types, its interface and the typed
failure verdicts. The only logic in it read what a cleanup or a stop proved
about the provider child's exit: rethrowing a failed acquisition with the
verdict its cleanup reached, and reading whether a stop left the provider
root gone. That logic moves unchanged to
structured-agent-session-provider-exit-proof.ts, which imports the verdicts
from the adapter; nothing imports back. Its six importers now import from
the new module. No behavior change.
…setting (stablyai#24133)

* feat(ssh): complete the relay runtime fallback ladder (D6 rungs B slot, C, D)

Rung C runs the relay on the host's Node >= 18 with Orca's prebuilt N-API
addons and no npm (addon-only probe mode). Rung B is a data-driven slot chosen
only when a compat runtime is listed. Rung D fails the connect with a
classified reason carried as a TerminalUnavailableCause. The ladder steps
down only on classified refusals; unanswered probes throw. The rung decision
is persisted per host keyed by (glibc, runtime hash, Orca major), and
ssh_remote_runtime_resolved reports it once per host per session.

* feat(settings): SSH host runtime choice (Auto | Orca-managed Node | Host Node)

* docs(telemetry): describe ssh_remote_runtime_resolved

* fix(ssh): let a passing rung C disprove a remembered noexec; allow glibc-less compat runtimes

A remembered rung A noexec was re-persisted even after rung C self-tested addons from the same
~/.orca-remote tree, so rung A stayed skipped until the key changed. Rung B's evaluator also could
never match a musl compat runtime.

* test(ssh): import node:fs once in the host-node addon test

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…lly receives (stablyai#24343)

The parent workflow collapses canary-apply and batch-apply into the job mode
apply, so the headroom step's canary-apply/batch-apply condition never held and
the gate was skipped on every real roll. Run it wherever the drain runs (apply,
rollback before its restart) and in read-only verify; skip only a resumed
rollback, which drains nothing. A new workflow-shape test fails on any job
step comparing against a mode the parent cannot pass, and on a drain that can
run without the headroom check.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
* feat(ssh): pinned-Node relay on Windows SSH hosts (D5 Windows, D2)

Windows hosts opted into remoteRuntime 'pinned-node' now get the same rung A
relay POSIX hosts do, instead of an early host-Node fallback.

- Runtime store: the official node-v24.21.0-win-<arch>.zip is uploaded to a
  stage under %USERPROFILE%\.orca-remote\runtimes, verified against the pinned
  archive hash, node.exe extracted with System32 tar.exe (Expand-Archive
  fallback), hashed with Get-FileHash, run once, and published with
  node.exe + .verified by one Directory.Move. One powershell.exe per phase via
  the existing powerShellCommand helper; the probe also creates the stage. No
  new -EncodedCommand site, no -ExecutionPolicy, no Add-Type. node.exe keeps
  its real name at runtimes\node-<sha>\node.exe.
- Bytes that change or vanish after Orca wrote and verified them are reported
  as ORCA_NODE_RUNTIME_SECURITY_MODIFIED and become a remembered
  'security_software' refusal (fallback to the host-Node relay); application
  control blocks classify as 'noexec'.
- Addons: the win32 slot's conpty.node, conpty_console_list.node,
  conpty\conpty.dll + OpenConsole.exe, watcher and windows-process-tree.node
  ride with the relay; the orcad template now carries the win32 targets.
- Self-test on Windows is one powershell.exe running relay.js on node.exe; the
  report must name the pinned Node. The relay self-test loads conpty.node and
  opens a PTY with useConptyDll, and reports a missing bundled ConPTY file as a
  load failure. A pinned relay's terminals use the bundled ConPTY too; host-Node
  relays are unchanged.
- describeRelayRuntime recognizes the Windows store layout.

* fix(ssh): skip the redundant stage-cleanup powershell.exe after a Windows runtime promote

The promote script already removes its stage on every path, so the client-side
cleanup only runs when promote never returned (upload failure, abort, timeout).

* test(ssh): expect the ladder's remembered flag and pin check on Windows pinned relays

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…ack (stablyai#24136)

* feat(ssh): run runtime-store GC after a pinned relay launch, under a store lock (D5)

The pinned-Node relay deploy now collects runtimes/ after a successful
launch, keeping the pin it runs. Promotion in ensureRemoteOrcadNodeRuntime
and GC deletion both hold runtimes/.store-lock (install-lock primitives,
20-minute stale rule); GC only tries the lock and skips when busy. A cold
pinned install re-checks its runtime under the lock once the relay ref is
visible, closing the ensure-then-ref window. GC also sweeps runtimes/.stage-*
dirs nothing has written to within the stale rule.

* feat(ssh): stream relay and runtime uploads over exec stdin when SFTP is refused (D5)

On the bundled ssh2 transport to a POSIX host, a definite SFTP refusal
(subsystem refused, sftp-server exited during the handshake, or a chrooted
view answering NO_SUCH_FILE for a shell-created path) now falls back to
writing through an exec channel's stdin, reusing makePosixWriteFileCommand
with a byte-count check and atomic rename. Transport loss, timeouts and
aborts never select the fallback. execCommand gains a stdin option.

* test(ssh): answer the runtime store lock in the OpenCode runtime setup test

Promotion now runs under runtimes/.store-lock, so the mocked host must grant
the lock and the stage-exhaustion case makes four more round trips.

* test(ssh): rung C relays never take the runtime store lock

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
…ials (stablyai#24347)

* fix(relay): let a draining cell pass restart-safe through refused redials

A draining cell refuses every control and host proof, so once no session,
splice, or queued byte remains, an in-flight or reserved connection unit can
only belong to a dial the cell is about to refuse. The restart-safe wait no
longer resets its pace-window streak on those units, and its progress line
now prints every counter the gate reads.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* test(relay): pin fail-closed parsing of handshake counters on a draining cell

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
A browser tab has no native window buttons, but a Mac user agent still reserved that left gap. Desktop macOS keeps the inset.

Co-authored-by: Cursor <cursoragent@cursor.com>
The inset token and the pad were each passed isWebClient by their own call site, so a test of the helpers could stay green while one site stopped reading the flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
@innocarpe

Copy link
Copy Markdown
Owner Author

Sync update (d34dabbcc9)

The inset width and the pad now come from one read of the paired-web flag. A test sets ORCA_WEB_CLIENT and checks both results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.