mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
feat(terminal): add Reset Terminal that clears leftover input modes on the host and pane (#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 ofa88e1eaa0a,f5b99d0003and5af1c6d97a: 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 to21954dbd2f, 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 (f00bebba48) 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 (21954dbd2f): 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 (b3b1b0def2) is the last commit to touch a fenced path, so `baseline` moves to it and every golden is re-recorded. Against97b5bb2b9a: 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-record85d421963cis the last commit to touch a fenced path. Againsta676c1b65a: 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-record486566c82bis the last commit to touch a fenced path. Against3371c39715: 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 commit486566c82b, 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 commitfae0ae7a46. * 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 store fallback copy picks its words and cause by situation, so a stale replay or a moved lease no longer reads as a latched owner. Every failed start is worded by structuredAgentSessionStartFailure(cause, context), which returns the row sentence and the typed fact together; the delivery loop, the exit settlement and the dispatch that met a starting child all call it. Provider diagnostics are capped at the lease record's 512 characters wherever a fact is built. * refactor(native-chat): one reader of why a submission was rejected classifyDispatchRejection(submission) returns { category, verdict, kind? }. It reads the typed rejection fact when the row carries one this build can place, and the legacy markers otherwise - all six, including not_delivered, which released clients printed as it was. The verdict is null only for a withdrawal, a host restart and a closed chat; write failures, a full queue and not-delivered stay failures. It replaces dispatchRejectionReasonIsInternal and every string comparison against the markers: the outbox reconcile, the rejection notice, and the send disposition, where a replayed Stop-withdrawn send no longer surfaces as a failed send. The journal reducer's echo-aliasing guard reads the narrow isWriteFailureSubmission, which matches the typed kind or the legacy prefix in any dispatch state, so its behaviour on legacy unknown rows is unchanged. * test(native-chat): pin provider diagnostics where they are composed The Claude exit status and stderr, the Codex stderr tail and Codex's JSON-RPC message are each checked at the place Orca composes its own error around them, so the typed detail is proven to come from the provider's value and never from Orca's wording. * fix(native-chat): an attachment Orca refuses says which limit it broke The content check's refusals (20 images, 5 MB per image, 20 MB in total, supported types) are written for a person, but the rejection writer replaced them all with one generic sentence. Each refusal now carries its own sentence, in MB rather than bytes, and the writer records it beside kind attachmentInvalid. Only an attachment that could not be read keeps the generic sentence. * fix(native-chat): the chat tab table refuses with a typed cause Showing a chat tab refused with bare Error('agent_session_conflict') and Error('agent_session_identity_required'), the only chat-reachable refusals still thrown without a cause (opening a chat from history can reach the second when the chat is removed mid-open). Both now throw the typed refusal; wire code and message are unchanged. * fix(native-chat): a compaction Codex refuses up front keeps Codex's words When Codex refused thread/compact/start, the adapter passed on only Orca's wrapped error text and dropped Codex's own message, so the chat's row read just "Compaction failed." The refusal now carries Codex's message as the failure detail, as a compaction that fails later already did. * fix(native-chat): an unreadable chat record no longer promises an update fixes it The recordUnreadable copy said a newer Orca saved the chat, but the store marks a record unreadable for damage and key mismatches too, where updating does nothing. The sentence now says Orca can't read it and gives both next steps. * fix(native-chat): a restart or a close leaves released clients a sentence, not a marker host_restarted_before_delivery and provider_closed_before_delivery are not in the markers released desktop and mobile builds hide, so they printed raw on every rejected message a restart or a chat close left. Neither marker has shipped. New rows carry a sentence plus kind hostRestarted or chatClosed, as not_delivered already did; the classifier still reads both markers, and the verdict for both stays no-failure. * fix(native-chat): log why an attachment could not be read The rejected message now says only that the attachment couldn't be read, so the error that said why (a missing file, a permission, or an unexpected throw) went nowhere. It is logged instead. The content rejection moves beside the content check that owns its errors. * refactor(native-chat): drop an unused thrown-refusal cause reader It had no callers, and its comment claimed it looked through wrappers, which it did not. * fix(native-chat): a compaction the provider never confirmed is recorded as unconfirmed, not failed * fix(native-chat): an empty or non-user message is not recorded as a bad attachment * fix(native-chat): an undelivered preamble's error ends in one period * refactor(native-chat): a compaction ends as compacted, failed, or unconfirmed, never an unlabelled error * refactor(native-chat): a failure's sentence is written only from its fact A writer could choose its sentence and its kind separately, so five writers put hand-written words beside a fact that said something else. One shared constructor, agentSessionFailureWords(fact, { surface, agentName }), now makes both, and the journal types refuse anything else: a status row, a rejected message or a conversation command that carries a fact must carry the sentence that constructor branded. Persisted rows keep their shape. - The sentence table and the restart table move to src/shared, as the English default a client copy table can reuse. - A rejection's legacy markers come from the same constructor. A write failure is now the bare `provider_write_failed` marker, which released clients already hide; its error goes to the log. - An image Orca refuses carries which check it failed (and the limit) in the fact instead of a sentence; an empty message gets its own kind, and a non-user message is Orca's fault. - The exit row, the interrupted compaction, the /clear failure and the rewind placeholder no longer carry their own words beside a fact: the exit row says what its fact says, and the placeholder carries no fact. * fix(native-chat): a start that failed without an observed exit no longer blames the provider Every untyped start error was recorded as "The provider stopped before it finished starting.", so an Orca fault, a failed spawn or a close that ended a start was blamed on the provider. Only an exit the adapter observed says so now: the Claude adapter marks the error it saw the child exit with, and anything else is a new `startFailed` kind, "<Agent> couldn't start.", keeping the provider's diagnostic when the error carried one. A child gone with no end observed, and a /clear whose new conversation was refused, read the same way. * 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 chat a terminal agent still holds says to quit that agent The merged base words a restart a terminal agent's claim refused with the refusal's own message, which names the process. That message is Orca's text and never reaches a durable row here, so the row read only "<Agent> couldn't restart." and lost the one step that frees the chat. The restart sentence now derives it from the refusal's cause: a claimConflicted refusal adds "This chat is still open in a terminal agent. Quit that agent to continue the chat here." The live refusal still names the process. The restart-resume ledger test now sets up a claim the base still refuses: a terminal owner that is proven running. * refactor(native-chat): keep the changed files inside their lint limits The legacy-marker lookup is a table, not a non-exhaustive switch; the preamble tests read the error without a cast; and the Codex history refusal goes through a named constructor so its file stays under the line limit. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * refactor(native-chat): refusals carry details keyed by their code A refusal named its situation with one flat cause list shared by every code, so nothing stopped a site pairing a code with a situation that code never means, and the loose fields released clients read (fence, revision, resolution, verdict, rewind reason) were written by hand at each emitter. A refusal is now one variant per code with optional details: a reason that code lists plus that code's own facts. refuse(code, details, message) rejects a reason the code does not list at compile time, and it is the one place the loose top-level fields are copied from details, so released clients read exactly what they read before. A site that cannot name its situation uses refuseUnclassified, which carries facts but no reason, the same as an older host; there is no catch-all reason. Thrown refusals put { refusal: { code, details } } in the RPC error's data (wire code and message unchanged). The operation ledger, the restart-resume record and a restart failure's embedded refusal keep details beside the code and read them back against it; a row an unreleased build wrote with a cause parses and reads as naming none. * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * test(native-chat): a restart-resume failure keeps its refusal details The recovery capsule reads a failure's details back against the refusal code in its reason: facts the code does not list and a reason another code owns are dropped, and a record an unreleased build wrote with a cause still parses, naming nothing. * test(native-chat): import the failure words once in the provider child test The merge left two imports of the same modules. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * fix(native-chat): a start the provider refused or Orca broke no longer says the provider stopped A failed start whose cleanup proved the child gone was typed as `providerStartFailed` whatever failed it: Codex refusing to resume a thread, a timeout, or Orca's own store fault. The chat then read "The provider stopped before it finished starting.", which was untrue, and the provider's own words were dropped. A refused restart also inferred the same from an `exited` verdict, which only says nothing runs now. Only an exit the adapter observed names that situation now; anything else is refused with no reason, keeping its verdict, and the chat reads "<Agent> couldn't restart.". The provider's words travel host-side from where the acquisition failed into the start-failure fact's detail, never onto the refusal, and the sentence does not quote them. What failed is logged once where the start failed. * fix(native-chat): a refused /clear start keeps the situation it named The replacement start that /clear makes built its own start-failure fact, so a refusal that named its situation, such as not being signed in or a history too large to restore, was recorded as a bare "couldn't start". It now takes its fact from the same start-failure function as every other start, as a new session that failed to start. * fix(native-chat): the journal schema and comments describe a refusal's details, not its cause The persisted failure fact's schema still described `refusal.cause`, which this branch replaced with `details`. It now describes `details` as an optional open object; a row an earlier build wrote with a `cause` still parses. A comment and three test descriptions that still named the cause now name the details. * fix(native-chat): a Claude child that exits while being acquired still reads as the provider stopping Now that only an exit the adapter observed says the provider stopped, the exit the Claude adapter saw during acquisition has to be marked where it is seen, as the exit after acquisition already is. Without the mark, a Claude CLI that exited at spawn read "Claude couldn't restart." instead of "The provider stopped before it finished starting." * fix(native-chat): word a failed chat start's refusal as its start failure A chat whose agent failed to start answered the create with the raw error: the launch strip read "Chat could not be started. claude stream-json exited (code 1): claude: not signed in", and the ledger replayed the same text. The first answer and the replay now carry the sentence the chat's start-failure row reads as ("The provider stopped before it finished starting.", "Claude couldn't start.", the not-signed-in and history-too-large sentences), and the raw error goes to the log. A store refusal's code and the unproven-exit marker are unchanged. * fix(native-chat): show Claude's API retries as one sentence row While Claude retried a refused request (a 429, say), the chat gained one red row per attempt reading "rate_limit", with the raw retry frame behind Details. Each retry run now writes one warning row that later attempts revise in place: "Claude is rate-limited and retrying." for a rate limit (error `rate_limit` or status 429), and "Claude hit a temporary problem and is retrying." otherwise. The row carries a `providerRetrying` fact with the provider's error type and status, and the frame as a log detail capped at 512 characters. * fix(native-chat): tell the user to run /clear again when its new conversation can't start When /clear's replacement conversation failed to start, the result told the user to "send your message again", which would go into the old conversation. The failure words now take the command the start was for, so a failed /clear reads "Codex is not signed in for the selected account. Sign in, then run /clear again.", "Codex couldn't start. Run /clear again." or "The provider stopped before it finished starting. Run /clear again." A message send keeps its wording. * test(native-chat): expect a failed Claude create to be refused in a sentence The runtime suites asserted the CLI's stderr reached the create refusal; it now goes to the log and the refusal reads as the chat's start failure. * fix(native-chat): name the agent that stopped starting and say how to retry a failed start A start the provider ended now reads "Claude stopped before it finished starting." (or "The agent ..." when the chat's agent is unknown) instead of naming "the provider". A start or restart that failed with a chat left to retry now ends in "Send your message to try again.", or "Run /clear again." for /clear; released clients print only this sentence. A message rejected at dispatch because its child died while starting names the same agent as the start's row. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * fix(native-chat): answer a create refused before spawn in the words its replay reads A create that failed before any process started threw Orca's own error text as its first answer, while its replay from the operation ledger read the generic start sentence. The two refusals a person can act on, a launch whose Anthropic sign-in variables override the managed Claude account and a Claude account switch in progress, are now typed where they are thrown and worded by the shared failure constructor, so the first answer and the replay say the same thing. Every other pre-spawn failure reads the generic start sentence, with its own text in the log. The first answer keeps its wire code; a message that is itself a code is unchanged. * fix(native-chat): say how to retry after an agent stopped before it finished starting "<Agent> stopped before it finished starting." gave no next step outside /clear, unlike every other failed start. It now ends "Send your message to try again.", the same step a start or restart that could not run gives; after /clear it still says "Run /clear again." * fix(native-chat): say how to reach a Claude chat when a WSL Claude account blocks it A Claude chat that restarts while a Claude account is added in WSL and no Windows Claude account is selected was refused before spawn with Orca's own text as its first answer, and the generic start sentence on replay. The refusal is now typed where the account gate throws, and both answers read the same sentence: choose or add a Windows Claude account in Claude Accounts settings, then send the message again. Account settings that cannot be read name no situation and keep the generic sentence. * fix(native-chat): say the reason a host names for a refused chat write A refused Stop, answer, setting, goal, command or queued message now reads the refusal's reason as well as its code. A code stands for several situations, so the code alone could only say what did not happen; with the reason, the notice says why and, where the person has a step to take, what it is: "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." The phone uses the same words. The notice table keys on code, then reason, then the kind of write. Every reason of every code has an entry, so a reason the host adds does not compile until it has words; a reason whose honest words are its code's keeps the code's row. A refusal with no reason, or one this build does not know, reads exactly as before, which is what an older host gets. A start that failed reuses the failure row's own sentence rather than a second one. A queued message keeps the reason, the rewind reason and the owner's verdict with its saved failure, and a rejected one keeps the host's typed fact without its provider detail. Nothing that moves with the owner or comes from the provider is saved; entries saved before this load as they were. The saved failure moves to its own module beside the words chosen from it. * fix(native-chat): retry a refused send under a new id only once its agent is proven gone A send refused because Orca could not tell who owns the chat keeps its operation id, since the first attempt may still land. When the refusal also says the agent process has exited, nothing can run that attempt, so the message moves to its Retry row under a new id instead of holding the queue. The owner's verdict is read as a floor: a saved `exited` is final, and any other saved verdict never changes the id on its own. Only a verdict re-derived from the current lease can raise it to `exited`, and nothing lowers a saved `exited`. Today no host sends a verdict on a send refusal, so nothing a person sees changes; the rule is in place for the saved verdict a reload reads back. * fix(native-chat): say a /clear that never finished did not finish, instead of that it cleared the chat A send into a chat whose /clear started but never committed was refused as if the conversation had been cleared: "This conversation has been cleared. Your message was not sent. Open the current conversation to continue." The clear never finished, so its new conversation may not exist and there is nothing to open. That refusal now has its own reason and reads "The last /clear didn't finish. Your message was not sent. Start a new chat to continue.", which is the only way on today. Its message for released clients says the same: "The last /clear didn't finish. Start a new chat to continue." Only a committed /clear still says the conversation was cleared. * fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send the provider's history proves it never received. Nobody failed that send, but the verdict table treated it as a failure. Each rejection kind now has its verdict in one exhaustive table, so a new kind does not compile until its verdict is chosen; no verdict for a withdrawal, a host restart, a chat close, or this lost send. A rejection whose kind this build cannot place, such as one a newer host added, now reads as undelivered with no verdict instead of falling back to the reason beside it: all it proves is that the message did not happen. The host keeps such a fact's kind when it reads the row back, rather than dropping it and letting the reason decide. Only kinds that can be why a message was not sent may reject one, by type: compaction, cancel/answer confirmation and provider-retry kinds stay on status rows. The one dispatch-row builder takes its input from the type that makes a rejected row carry its fact. * fix(native-chat): a send refused after its agent exited keeps its place in the queue When Orca cannot tell who owns a chat but the refusal says the agent process has exited, the next attempt may use a new id, since nothing can run the old one. It no longer marks the message as rejected: nothing recorded it, so it still holds the head of the queue, and later messages wait behind it instead of being sent ahead of it. * fix(native-chat): stop reading a provider's words from an error that contains itself A cleanup that aggregates errors restarted the depth count for each one, so an aggregate error that contains itself recursed until the host ran out of stack. One depth bound now covers both the cause chain and the aggregated errors. * fix(native-chat): say a chat whose history can't be read can't continue, and to start a new one A read of a chat's history is refused with `agent_session_journal_unreadable` only when the chat's journal file is corrupt or not a database, which no retry can change. The notice table had no words for a read at all, so a pane had nothing to show but the raw code. Reading a chat's history is now its own request kind, `read-history`, and that refusal on it reads "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue.", whether the host names the reason or raises the bare code. A write refused under the same code keeps its words, because its cause is any failed open, which can clear. Any other read refusal says only "This chat's history couldn't be loaded." `agentSessionReadHistoryRefusalParts(code, details)` gives a pane those words from a read error. The notice sentences move to their own module so the table stays within its size limit. * fix(native-chat): decide what every way a child ends means for queued messages in one table What a child's end means for the messages queued behind it was an if-chain: a user's Stop was checked in one place, a host stop in another, and every other cause, including one added later, fell through to "the provider exited". It is now one table over every end cause, so a new cause does not compile until someone says whether it fails what is queued and how. A user's Stop still fails nothing, a host stop is still Orca's fault, and an exit, a failed attach or an eviction still carry the failure the end recorded. Nothing a person sees changes. * test(native-chat): a close that stops the child and then fails rejects what is queued by how the child ended When a chat closes, stops its agent, and then fails a later step, the chat stays open with its messages still queued. The delivery loop then rejects them by the way the child ended: an eviction during startup reads as a failed start, otherwise as the provider having stopped, and either counts as a failure. The cases are rows over the end cause, so another way a chat closes is one more row. * test(native-chat): type the refusal a persisted-schema test admits * fix(native-chat): say whether a chat's history is damaged or just couldn't open A write refused because the chat's journal would not open named one reason, `journalUnreadable`, for every failed open, and a read of the history took that same reason as final. So the words depended on what was asked, not on what happened: a busy or permission-denied open could tell a person to start a new chat, and a damaged one could read as something that clears. The host now decides at the refusal which it was. `journalCorrupt` is set only when SQLite itself reports the journal damaged or not a database (SQLITE_CORRUPT or SQLITE_NOTADB, extended codes included, read from the driver's result code and never from message text, through any `cause` chain). Every other failed open is `journalUnavailable`. A corrupt history reads "This chat's history couldn't be read, so it can't continue here. Start a new chat to continue.", with "Your message was not sent." before the step on a send. One that couldn't open reads "Orca couldn't open this chat's history right now. Try again.", likewise on a send. A host that names no reason gets "Orca couldn't read this chat's saved history.", which promises neither, because damage can't be proven from the code alone. The refusal's message, which released clients print for a send, is now that person sentence instead of the open error's own text; the error is logged instead. `journalUnreadable` is replaced outright: no released build wrote it, and a stored one reads as a refusal with no reason. * docs(native-chat): say why a history that couldn't open names its retry step * fix(native-chat): a failed start's row keeps the words its rejected messages carry When the delivery loop settles a failed start before the child's exit is published, it writes the start's error row and rejects every queued message with the adapter's startup answer. The exit settlement then rewrote the same row from the exit event, so the row could say one thing while the rejected messages, which are terminal, said another. The exit now leaves a start's row it finds already written. * fix(native-chat): a Claude start Orca itself failed no longer says Claude stopped Every error that ended a Claude session was marked as an exit the adapter observed, including a start Orca failed while the CLI was still running: a saved option whose restore lost its answer, an init frame naming another session, or a journal write fault. Those read "Claude stopped before it finished starting." although Claude never stopped on its own. The mark now stays where the child's exit is seen (the connection's exit callback), so those starts read "Claude couldn't start." with any diagnostic beside it, and a real exit before the start lands still says Claude stopped. * fix(native-chat): name the agent that stopped, and blame Orca for its own closes A chat that lost its agent mid-response said "The provider stopped…", and a started Claude session that Orca itself closed after a journal fault said the same, as if Claude had exited on its own. The exit row and the rejected-message reason now name the chat's agent ("Claude stopped while this response was in progress…", "Codex stopped before this message was sent."), or "The agent" when the name is unknown; the stale-state settlement now passes the agent name too. After a Claude start has landed, the ended event reports providerExited only when the child's own exit was observed; any other close is Orca's fault and reads as one. * test(native-chat): pin the sidebar verdict to the rejection classifier for every kind and legacy marker * fix(native-chat): blame Orca, not Codex, when Orca closes the Codex child A Codex chat that Orca itself closed (a journal sink that could not take a frame, or a forced close) said "Codex stopped while this response was in progress", as if Codex had exited on its own. Orca's own close path now reports hostFault. providerExited is left to the app-server connection's exit callback, which the connection withholds while Orca is closing the child, so it only ever reports the child's own exit. * fix(native-chat): a chat whose history is damaged reads "Unable to load this chat." * fix(native-chat): route the conversation-outlives-agent writers through the typed refusals Three writers that arrived with the merge wrote refusals the old way: - An operation that starts the agent itself, such as a goal change, turned any error the start threw into a refusal whose message was Orca's own error text, which released clients print. It now logs the error and says only that the agent couldn't restart. - An option picked while the chat is at rest, for a key the provider would not accept, is refused with the rejected-option reason, like the same pick on a running agent. - A restart continuation whose agent was refused a start filed the rejected message's sentence as the failure's reason. It files the refusal's code with its details again, which is what the restart-failure guidance keys on. * fix(native-chat): a start Orca stopped because it never finished reads as that The idle sweep now stops an agent whose start never finished and rejects the messages waiting on it. The chat read "Orca ran into a problem, so this didn't go through. Try again." for that, because every host stop was worded as Orca's own fault. It now reads "Codex never finished starting, so Orca stopped it." in the chat's row and on each rejected message, carried as its own failure kind so newer clients can tell it apart. The message counts as failed, like any start that did not land. * test(native-chat): pin the merged close and host-stop rows to their typed facts The merge left two expectations on the old words: the close tests looked for the marker a close used to write, and the host-stop test for the host-fault sentence. A close now writes "The chat closed before this message was sent." with its fact, and a host stop the hostStopped sentence the constructor gives, whatever reason the stop carried. Also folds the conversation command's two imports from send preparation into one. * fix(native-chat): a read of a chat this host cannot open says why Reads now reach a chat through one accessor, which refused a missing record and a provider this host does not run as a bare code with nothing beside it. Revealing the same chat already names those reasons, so a client could tell "this chat no longer exists" and "update Orca" apart there but not on the history or subscribe read that follows. The accessor now throws the same typed refusals. The wire code and message are unchanged; the reason rides only in the error's data, which released clients ignore. * test(native-chat): a Claude retrying past the idle window keeps its conversation open Every api_retry frame publishes the journal, and that publish is the activity the idle sweep reads, so a retry run revised into one row still renews the clock on each attempt. --------- Co-authored-by: Claude <noreply@anthropic.com> * perf(ci): stop duplicating shared Linux download caches (#23578) * perf(ci): pilot pnpm verification record caching on Linux * test(ci): review pnpm verification record in mobile cache audit * perf(ci): share Electron downloads and clean closed PR caches * test(ci): retain cache ordering checks for restore-only consumers * perf(ci): limit archive sharing rollout to primed Linux hosts * perf(ci): run static analysis on the free ARM runner (#23576) Measured 128s against 172s on ubuntu-latest, with every compute-bound step faster: type-aware 24s to 15s, anti-slop 28s to 19s, localization extraction 67s to 46s, and the orcad terminal smoke 39s to 14s. Checkout and the install were unchanged at 12s and 16s. The toolchain resolves on arm64: both lint engines ship linux-arm64 bindings (@oxlint/binding-linux-arm64-gnu, @oxlint-tsgolint/linux-arm64), and build-orcad-bun.mjs derives its target from process.arch. The orcad smoke booting and round-tripping a real PTY is the evidence that node-pty compiled and that Bun, the bundled ripgrep and @parcel/watcher all resolved. The runner is free for public repositories, the same one the typecheck job already uses. * fix(orchestration): reland process-incarnation reap for stale worker terminal handles (#23583) * fix(orchestration): reland process-incarnation reap for stale worker terminal handles Relands the orchestration part of #18790, which #22601 reverted in full because that squash commit also bundled an unannounced agent. Only the worker terminal-reap fix returns; no agent catalog changes. When a worker's saved terminal handle stops resolving while its process is still running, worker-show/read, worker-stop and worker-release now re-mint a live handle from the recorded process incarnation (exact pty id + incarnation id, same host scope) and act on it, instead of reporting the terminal missing and leaving the process running on the host. Fixes STA-8493 * test(orchestration): spy on the public incarnation methods instead of casting the runtime * feat(agents): add Freebuff launch and sidebar status support (#23567) Add Freebuff launch support and execution-host status reporting for the sidebar, including running, question, blocked, and settled states. Validate against captured CLI transcripts and real rendered sidebar evidence. Cross-referenced community implementations #17065, #20839, and the Freebuff portion of #18790. Preserve their agent/catalog/mobile/documentation coverage and add canonical status publication and regression tests. Co-authored-by: Harkaran Brar <18134082+harkaranbrar7@users.noreply.github.com> Co-authored-by: Prarambha369 <98906077+Prarambha369@users.noreply.github.com> Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com> * perf(ci): move six more jobs to the free ARM runner (#23594) * perf(ci): move six more jobs to the free ARM runner Follows the static-analysis move, which measured 172s to 128s. Each of these was checked for an x86 requirement rather than assumed portable. pr.yml: cross-version-wire source-only, tagged checkout plus in-process vitest managed_hook_node18 Node 18 publishes linux-arm64; the per-platform runtime files are read as data, so host arch is moot codex_index_heal_contract @openai/codex ships @openai/codex-linux-arm64 shell_contracts fish 4.x is published for noble/arm64, zsh is in the arm64 archive, so the fatal fish-4 gate still holds mobile.yml: verify 209s of its 298s is Vitest; no Android SDK, emulator, gradle, Hermes or Watchman, no docker, no artifacts. Gemfile.lock lists the generic `ruby` platform, so frozen bundler resolves without an aarch64-linux entry recording-pin pure Node plus git; the golden comparison masks `platform` Left on x86 deliberately: package builds --x64 targets, its docker gates are --platform linux/amd64, and it is where the glibc floor check runs. node-pty's .symver pin is arch-specific, so flipping would validate the arm64 pin and stop validating the shipped x64 one orcad_browser Google ships no Linux arm64 Chrome mobile_web_app same Chrome wall; its render check fails closed git_compatibility its cache key carries runner.arch and the warmer is x86, so flipping alone means a cold `make git` every run relay_integration no technical blocker, but x86 relay coverage is a documented placement and the reusable workflow has no per-job runner input e2e and the ssh lanes the ssh jobs would silently retarget the tested remote from linux-x64 to linux-arm64 * perf(ci): move xterm_patch_sync to ARM too The patch check rebuilds 4 packages x 2 builds and byte-compares against the checked-in bundles. Ran it on darwin-arm64: exit 0, in sync at 1362743 bytes, with the full fetch-and-rebuild path exercised rather than a short-circuit. The bundles were generated on Linux x64 and reproduce byte-for-byte on a different arch and a different OS, so the output is host-independent. * Fix status bar on smaller screen (#23587) * fix(status-bar): adapt layout to smaller screens with responsive density Replace fixed breakpoints with a responsive density system that measures the status bar's content and picks the roomiest density level that fits. At progressively tighter widths, the bar sheds usage mini bars, segment labels, and finally collapses calm usage chips into a "+N" overflow indicator — keeping every feature one click away in the Usage popover. Usage-first collapse order ensures urgent providers stay visible longer. * fix(status-bar): use overflow-clip-margin to preserve focus rings Adds a 3px overflow-clip-margin to status bar containers so focus rings and badge dots aren't clipped when content overflows on smaller screens. * feat: add first-class Qoder CLI support (#23581) feat: add first-class Qoder CLI support Integrate Qoder launch, identity, canonical hook status, trust and resume. Verify with captured Qoder 1.1.64 transcripts and hidden Electron sidebar checks. Builds on and cross-reviews #7502, #8611, #9655, #12910, #13311 and #15291. Co-authored-by: dalveytech-vincent <vincent@dalveytech.com> Co-authored-by: Eridanus117 <45489268+Eridanus117@users.noreply.github.com> Co-authored-by: xingqingzzp-gif <xingqingzzp-gif@users.noreply.github.com> Co-authored-by: jyang2004 <jyang2004@users.noreply.github.com> Co-authored-by: yunqian <yunqian@alibaba-inc.com> Co-authored-by: huzhening.hzn <huzhening.hzn@alibaba-inc.com> * ci: skip unrelated installs and share xterm build dependencies (#23607) * ci: pilot shared xterm installed dependencies * ci: bound xterm cache production to verified main entries * ci: benchmark xterm reuse on the production ARM runner * ci: avoid installing Orca dependencies for standalone xterm checks * ci: use Node-only setup in the production xterm job * ci: remove completed xterm benchmark workflow * ci: use faster gzip for temporary Linux test packages (#23609) * ci: benchmark faster Linux package compression * ci: pass compression options through typed builder configuration * ci: retain original configuration for benchmark baseline * test: preserve release settings in CI compression configuration * ci: normalize generated changelog dates in package comparison * ci: remove completed Linux compression benchmark * Update README downloads badge * fix(deps): take Electron 43.7.5 so detached webviews stop blanking browser tabs (#23586) Electron 43.7.0 threw 'Invalid guestInstanceId' from <webview>'s disconnectedCallback for a loaded guest (electron/electron#53989), so a webview React removed and re-inserted kept a dead guest id: the tab went blank, reload did nothing, and the destroyed listener never fired. 43.7.4 (electron/electron#54097) returns early when the guest is gone. Raises the runtime floor test to 43.7.4 so a downgrade cannot re-ship it. Fixes STA-8757 * docs(readme): remove the TestFlight link (#23660) * docs: update GitHub star history chart (#23661) * fix(claude): withdraw a follow-up queued behind a stopped turn instead of dropping it silently (#23553) * fix(claude): withdraw a follow-up queued behind a stopped turn instead of dropping it silently Orca's SessionStart hook proves most Claude starts before the turn's system/init, the only frame that advertises interrupt_cancel_queued_v1. Capabilities were read once from the start proof, so they stayed empty: Stop never asked Claude to cancel its queue, and the one-at-a-time fallback withdrew the follow-up without settling it. The send stayed pending and the chat read Working until the child exited. Capabilities are now one derived value, taken from whichever report names any and never cleared by one that names none. The fallback settles each send the CLI confirms it withdrew. * test(claude): pin that only a confirmed cancel_async_message counts as withdrawn * test(native-chat): await the async journal snapshot in the queued-stop test * test(native-chat): wait for the queued-stop states instead of sleeping A fixed 20 ms sleep does not cover the host's asynchronous journal writes on a loaded runner. * fix(codex): a message whose turn was stopped before Codex took it is withdrawn, not stuck (#23618) * fix(native-chat): land a late settlement from a streamed turn's end after that turn's rows A settlement that says a streamed turn ended waits for the session's event sink to drain before writing its dispatch row. The journal reducer still refuses to overwrite an accepted or rejected send. * fix(codex): settle a send from the end of the turn Codex answered it into The turn/start answer names the turn that holds a send. The send's echo entry now keeps that binding, in memory only. If the bound turn is interrupted without echoing the send, the send is withdrawn: Codex clears a turn's pending input on interrupt, so the model never saw it. If the turn fails first, the send is rejected in Codex's words. A completed turn settles nothing, since Codex records pending input when it finishes and the echo is still due. An answer read after its turn already ended is settled by that end. The echo is still the acceptance and carries the item key. * test(codex): a send settles from the end of the turn Codex answered it into The fake Codex keeps 0.157's turn bookkeeping, and can deliver the turn/start answer after turn/started or after turn/completed. The tests cover: - a Stop before any echo withdraws the send, and the working rule reads idle; - a steered follow-up is withdrawn when the turn is interrupted; - a failed turn rejects the send in Codex's words; - a completed turn leaves the send to its echo; - a normal echo and a late echo; - two steered sends in one turn; - an answer read after the turn ended; - a timed-out answer; - child-thread turns; - how a binding dies. * refactor(native-chat): drop the stream flush before a late turn-end settlement Nothing reads the order of a dispatch row against the turn's terminal row: the reducer keeps a settled send terminal and the working state is derived from both. The echo acceptance on the same path never waited either, and the wait could drop the settlement on a failed sink barrier. * fix(codex): settle a failed turn's sends at its end, not at its error Codex keeps a failed turn's pending input and records it after the error frame, before turn/completed. Settling at the error rejected a steered follow-up the model had in fact received, so a Retry would send it twice. * docs(codex): say a completed turn echoes what it took before it ends Codex records a completed turn's pending input before `turn/completed`, so a bound send that turn never echoed is left for recovery, not awaiting an echo. The comments and one test title said the echo was still due. * test(codex): settle a send whose answer is read after its turn failed or completed A failed turn that ended before the answer rejects the send in Codex's words, once; a completed one leaves it admitted and still armed for its echo. * refactor(codex): read a failed turn's reason with the typed thread-fact reader * Show a resting Claude chat's effort instead of a blank picker (#23106) * refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * fix(native-chat): the conversation outlives its agent Opening a chat no longer starts its agent. A conversation is reached through one host accessor that opens its journal at rest, and a send is what starts the agent, through the delivery loop. One idle sweep, every five minutes, stops an agent that has been quiet for thirty minutes and owes no work, then drops an open journal handle that is only a cache. Its record, tab, status row and readers stay. - hold and release are no-ops; hold still builds the host for shipped mobile builds. - The holders, the holds, the release clock and the exit respawn are deleted. - Options, the model list, the goal and the context meter answer at rest; a model pick at rest is recorded as intent for the next start. - Compact, rewind, clear and goal changes start the agent first. A send does too when a rewind is still in doubt after the conversation opens. - Orchestration routes mail and group addresses on ownership (the record plus the chat tab), not on whether the process runs. An open dispatch keeps its worker running. - The restart continuation is a send; Resume all holds each slot until the message is handed over or rejected. - A read error never replaces a loaded transcript, and shows the host's own words. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * fix(native-chat): a restart offer ends when the chat's agent starts again The offer used to end only when the chat's newest user message changed, because opening a chat started its agent and that start could not be told apart from real activity. Opening a chat starts nothing now, so the host reads the fact it already publishes: a chat's status row goes from not host-owned to host-owned exactly when its agent is started. At that edge the offer and any failure record for the chat are withdrawn, unless the start is a resume action's own (its continuation is the oldest undelivered message). A continuation and a message racing to be first are decided at acceptance: the continuation is refused, quietly and with nothing filed, when any other message was accepted since the restart. A failed continuation start leaves the offer retryable, and each resume action sends its own message id. Deleted: the newest-user-message comparison, its journal reader, the continuation filter, and the failure ledger's own "answered by the chat" check. The marker still carries its message id for one release, so the previous build can read it. * fix(runtime): end a transcript stream when its client unsubscribes Desktop: the IPC subscription controller was dropped as soon as the streaming handler returned, which for most streams is right after it binds. A later runtime:unsubscribe then found nothing to abort, so the host kept the subscriber and derived and sent every publish to a channel no one listened to. The controller now lives until the renderer unsubscribes, resubscribes the same id, or goes away. Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe with the stream's frame id, so the host ends that subscriber and leaves a sibling stream on the same socket running. The direct path now passes the frame id the relay path already passed. * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * fix(native-chat): one fact ends a restart offer: the chat moved on since the restart The offer is live while no other message has been accepted in the chat since the restart and its agent has not proved a start since. The offer list, the resume's reservation check and the continuation's acceptance check all read that one fact, so a message whose start then failed withdraws the offer too, and a stale click finds nothing to act on. The fact is read off the conversation's open handle, which the restart closed, so it is retired durably whenever it may have changed: a message accepted, a start proven. A close and reopen within the same run therefore cannot bring the offer back. A continuation rejected before it reached the agent does not count, so a retry after a failed start still runs. Deleted: the quit-time gate on withdrawal, which changed nothing because the withdrawal and the quit's own offer write share one queue; the per-action "withdrawn" flag and the separate acceptance check it paired with. * test(native-chat): an older build reads the restart offer this build records The offer lives in a file the previous release reads after a downgrade. Pin that against the pinned release's own capsule, and run the lane when the marker or the capsule changes. * fix(native-chat): read a restart offer against where the journal stood when it was taken "Since the restart" was read off the conversation's open handle, which the idle sweep closes: after a reopen, a message the user had already sent looked older than the handle and the withdrawn offer came back. The offer now records the journal position (epoch and sequence) at the moment it is taken, and a message accepted after that position, or a journal on another epoch, means the chat moved on. That is derived from the journal, so it holds across any number of closes and reopens. An older build's offer has no position; only a start withdraws it. Because the message half is now durable, the offer is no longer rewritten in the recovery file on every accepted message; a proven start still writes it, since only the host that saw the start knows of it. * test(native-chat): wait for the listing's retire write before reading the recovery file * fix(native-chat): keep the terminal-backed chat's read error over its local echoes Messages winning over a read error is right for the structured chat, whose read retries and whose messages came from the transcript. The terminal-backed view assembles its list from local echoes too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no error. Only the structured pane now keeps messages over an error. * fix(native-chat): a start retries the exit settlement a failed journal write left owed An agent exit whose journal settlement write failed releases the lease latched until a retry lands. Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried it before the next app launch, and every send was refused. The start the send needs now runs the retry first, where the attach would. * perf(native-chat): answer the owner check without opening the chat Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the answer comes from the session record alone. Reaching it through the accessor opened each resting chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it open for the idle window. It now checks the record and the adapter's support, as before this series, and opens nothing. * fix(native-chat): a read waiting on the session lock opens nothing once quit began The accessor checked for quit before queueing the open, so a read queued behind a session task ran its open after teardown had begun and indexed a journal no teardown step would close. The check now runs at the open itself. * fix(native-chat): read a failed resume's chat before calling it retryable Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the restart, read from its journal. The failure list read it only for a chat already open, so once the idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did nothing. The list now opens the failed chats first, as the offer list does. * test(native-chat): type the provider event sink the settlement test reaches for * fix(native-chat): say the structured read keeps trying only where it does The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an untranslated fallback whenever the read error had no text, and the empty state prefers any message. The view state now leaves the message out, so the structured pane shows that line and the terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an error frame, so it no longer makes the claim. * test(native-chat): await the send's settlement instead of polling for the start The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a loaded machine outran. They now await the host's own settlement of the message. * fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now dated by the resume action. Telling a rejected continuation from the user's own message read the operation ledger, whose rows expire after about a day; after that a failed resume stopped being retryable. The offer now records the continuation each action sends on its own capsule entry, bounded to the newest 16, so the ids end with the offer. The ledger read is deleted. * fix(orchestration): route no mail to a structured worker its orchestration released A structured worker is routed on ownership, and a resting worker's lease is released, so ownership held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one restarted its agent. Routing now also reads the orchestration's own resource row: once it is released, direct mail, group addressing and worker-show's addressable answer drop the worker, as they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored. * fix(native-chat): a failed retry names the user's prompt, not Orca's continuation A resume's continuation is written to the chat before its start, so after a failed attempt the chat's newest user message is that rejected continuation. A second failure then showed Orca's own restart text as the chat's prompt. A retry now keeps the prompt its first failure named. * fix(orchestration): read the released row optionally, as the authority does worker-show's observation called the row lookup directly, which a runtime double without it threw on and failed the structured tab-retirement release. * fix(native-chat): the status bar drops a restart offer the chat moved on from The renderer re-read the host's restart offer only when a failed chat showed activity, so after a message withdrew a pending offer the host answered no chats while the status bar kept counting one, and clicking it opened nothing. The same watch now covers pending offers: a status change in an offered chat asks the host again, once. * fix(native-chat): a resting Claude chat shows the effort its next start runs A running Claude child reports the effort it applies. With no effort pick of its own, that is what the CLI runs for the model when none is sent, so the catalog write-through records it as the model's defaultEffort. The store keeps a known default through a listing that names none, and the resting options read answers the pick, else that default, as a live child does. Nothing is saved as the chat's pick. * test(native-chat): a roster of idle or finished children does not keep an agent awake The sweep reads owed background work through the shared child-work liveness that upstream's release clock adopted; a child that went idle or finished is not work the agent still owes. * fix(orchestration): a task dispatched into a resting structured worker keeps it running The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's process incarnation now counts, derived from the existing rows. * docs(native-chat): comments stop describing the hold this PR removed Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it. Comment-only. * fix(native-chat): a restart offer keeps the start its own continuation made Whose start ended an offer was decided at read time, from whether the offer's continuation was still the queued message. Once the provider refused that continuation, the child it had started read as someone else's start, so the offer ended and its failure showed no Retry. The delivery loop now records which queued message a start is for on the in-memory child, and the child's end carries it; the offer counts a start as its own when that message is one of its continuations. * fix(native-chat): an agent gets a full idle window after its owed work ends The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can read done before the lead's wake-up turn writes anything, and stopping in that gap loses the wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full window afterwards, as the release clock it replaced did. * test(claude): the options-read fixture runs a live child The fixture marked its conversation running with a hasProviderChild field the session type does not have, so the read took the at-rest path and refused a session with no record. It now carries a child, which is what the read checks. * test(native-chat): host tests reach its collaborators through a typed seam The rest-test rig and three test files read the host's private members with Reflect.get and cast the result. The host now exposes one test-only accessor, collaboratorsForTests(), and the subscribers class a subscriberCountForTests() beside its existing retainedActivityCountForTests(), so the tests are checked against the real types and the casts are gone. * refactor(orchestration): one owner answers a structured worker's custody Routing, group addressing, worker-show and the idle sweep each composed their own reading of whether orchestration still holds a structured worker, so each new obligation or retirement state had to be added to every reader. structured-worker-custody now derives both answers from the worker-terminal list state coordinators see in worker-list: addressable is owned and not released, and owed work is an active custody or an unsettled task dispatched to the same incarnation. The owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests. * refactor(orchestration): owed work is an open dispatch on the worker's incarnation A supervised worker's own dispatch context stays open exactly while the worker is active, so the separate active-custody branch only repeated it. Owed work is now one fact, which also states the policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are written once at the top of the module. * fix(native-chat): a restart offer knows its continuations by a tag in their id The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running action's id in memory. Both could disagree with the journal: past the cap an old rejected continuation read as the chat moving on, and a crash during a retry restored the failure's older entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer (its teardown and chat), then the action's own part, so any continuation of this offer, queued or rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own continuation the start was for, read against the stored marker. * test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait The tui-idle probe reads through readTerminal, which now awaits the structured worker check before the PTY read, so the probe's snapshot request starts a microtask later. vi.waitFor missed it on its first check and polled again at 50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot then resolved after the wait had already timed out, so the test passed without judging it, and the rejection landed before any handler was attached. Vitest reported that as an unhandled error and failed the shard. Polling every 1 ms sees the request within a few ms, so the snapshot is judged while the wait is still pending. * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * fix(native-chat): the idle sweep reads owed work every tick Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it refreshed the clock at most once a window. Work that ended just before the next read left the agent to be stopped at that read, moments after the work ended, which is the gap the refresh was meant to cover. The sweep now reads owed work on every tick for a started agent, so the window always runs from the last tick that saw work owed. * fix(native-chat): a continuation handed to the agent stays sent The offer read its own continuation as not reaching the agent while its dispatch was pending, which also covered one already handed over and still unanswered. When the wait for that answer ended first, the failure it filed read as retryable, and a retry sent a second continuation to an agent that may have acted on the first. Only a continuation still queued, or rejected, is now read as unsent. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): the interrupted create's own retry continues again The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its own operation id, with a fresh start whose result nothing read. That fresh start passes with the released-reservation continuation deleted, so the case the fix exists for went untested. The retry and its assertion are main's again. * docs(native-chat): three comments that still had views starting agents A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction left alone would refuse every send, so no agent would ever start to finish it; and a current host raises the unattached read refusal only once quit began, with the attach window belonging to an older host. * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * test(native-chat): a reader's open settles the turn a failed exit settlement left running An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle sweep closed, and a read that opens the chat before the restart restore reaches it. * test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner A subscription reads the conversation before it returns, so under load the two views took longer than the create child's 300 ms start, which then exited before the test checked that it had not. The child now takes a second to fail. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the dead process's lease still reads live. The open settles the turn it left running anyway, and the restore that follows finds it settled. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * docs(native-chat): drop the removed dispatch hold from six comments A worker's session no longer takes a dispatch hold, and no release clock rests a chat by visibility; the agent-launch comments, the abandon test, the teardown test and the refusal census still said so. * test(native-chat): rest the owner-status chat through the idle sweep, not a hold The activation-gate test from #22808 put its chat at rest by holding and releasing it, and passed the release-clock grace. This branch deleted both, so the case threw before it reached its assertions. It now moves the host's clock past the idle window and lets the sweep stop the agent and close the conversation, then asserts the same owner answer and activation gate. * fix(native-chat): show the structured pane's retrying line when a read fails The read transport always hands the pane the host's words, so the error state's "Orca keeps trying to load it" line, which showed only when there were none, was never seen: the pane showed the host's text twice, as its subtitle and on the status line under it. The structured pane now always says its read keeps retrying, and the host's text stays on the status line. The terminal-backed chat is unchanged. * fix(native-chat): keep a resting Codex chat's unsaved effort blank, as its live child shows it The resting options read fell back to the catalog's default effort for every provider. A live Codex child answers only the effort its thread reported, which is none when Codex's config names none, so a resting Codex chat showed Medium and then went blank once it started. * test(native-chat): read the resting Claude chat from the record its start persisted The at-rest test built the record by hand, so nothing proved the model id a start persists is the listed row the catalog learns the default under. --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(release): stop the release policy from deleting pipeline-cut releases (#23669) * fix(release): stop the release policy from deleting pipeline-cut releases The policy judged a release by who created the release object. Cut Release reuses an existing draft, so a CI-built v1.4.216 whose draft a person had created was deleted (tag included) when its notes were edited, and Latest fell back to v1.4.214 because v1.4.215 was also published by a person. - Authorize a desktop release when its annotated tag was created by the release pipeline and points at its `release: vX` commit, not only by author. - Only delete on `published`; an edit never deletes a release or tag. - Pick Latest from the highest authorized stable using the same check. - Move the policy into config/scripts/release-policy.mjs with tests. * fix(release): load the policy module from the tagged commit Release events run the workflow file from the tag's commit, so checking out the default branch could pair an old workflow with a newer module. * fix(claude): write only the hook events and statusLine the user's Claude accepts (#23614) * refactor(claude): name the Claude version module after the hook events it gates Pure move of claude-session-end-hook-capability.ts and its tests; the next commit turns its one-event SessionEnd floor into a per-event version table. * fix(claude): write only the hook events the resolved Claude knows Claude 1.0.81 through 2.1.100 validate settings.json `hooks` against a closed event enum and discard the whole file on one unknown name, so Orca's install made Claude <= 2.1.77 silently ignore the user's env, permissions and hooks. Each managed event now carries the first Claude release that knows it (pinned to per-release enums read from the npm packages), and install, status and the SSH/WSL relay installer write only the events the resolved Claude accepts. An unresolved version gets the set every tabled Claude knows; a downgrade removes only Orca's own entry for an event the older Claude would reject. * refactor(claude): move the managed Claude hook events into their own module hook-settings.ts is at its line limit; the event list and its version gate move out whole so the next change has room. * fix(claude): an unresolved Claude version never removes Orca's hook entries A failed or timed-out version probe is no evidence of an old Claude, so it must not strip StopFailure, PermissionRequest and the other newer events a version-aware install wrote. With the version unknown, install adds only the set every tabled Claude knows and leaves every other entry exactly as it is; only a known version that lacks an event retires Orca's entry. * fix(claude): gate the core hook events on the Claude release that added them Claude validates hooks against a closed event list from 1.0.23, not 1.0.81. The table treated SessionStart, UserPromptSubmit, Stop, SubagentStop, PreToolUse and PostToolUse as known by every resolved version, so a Claude from 1.0.23 to 1.0.61 was still sent names it rejects, and it dropped the whole settings file. Pin each to its first release from the packed enums and keep the unresolved-version set as its own policy. * fix(claude): write Orca's statusLine only for a Claude that knows it Claude 1.0.49 through 1.0.66 also reject any unknown top-level settings key, and statusLine joined that schema only in 1.0.64. Orca wrote its statusLine for every Claude, so 1.0.49 to 1.0.63 still dropped the whole settings file even with the event gate. Gate statusLine on 1.0.64, pinned by the packed schemas; a known older Claude has Orca's own statusLine removed along with the opt-out marker, so an upgrade re-adds it. An unresolved version is now assumed to be 1.0.64, which knows the same core events and keeps the statusLine install it had before. * test(claude): a user statusLine opt-out survives a downgrade and upgrade Retiring Orca's statusLine for a Claude older than 1.0.64 forgets the install marker only when Orca's own statusLine was removed. Pin that, so a user who deleted Orca's statusLine is not opted back in by an upgrade. * test(claude): check the whole written settings file against each strict schema Claude 1.0.49 through 1.0.66 discard the whole settings file over any top-level key their schema lacks. The fixture recorded only whether each release knew statusLine, so a new top-level key Orca wrote would pass every test. Record each release's top-level keys instead (statusLine is derived from them), add the hook enums for every packed release in that window, and check that a real install and a downgrade write only keys and events each strict release accepts. * fix(native-chat): a failed Codex turn keeps its failure and "Worked for" (#23514) * fix(native-chat): a Codex turn's first terminal settlement is final Codex follows a turn-ending `error` with a failed `turn/completed` for the same turn. The error settled the turn and dropped its start and attributed send; the completion then re-settled it from nothing, so the record lost its start time and flipped from completed/failure to interrupted/failure. A failed turn lost its "Worked for" and read as interrupted. Both ends now go through one settlement that refuses a turn already in the recent-turns window, so every later end (error then completion, a duplicate completion) adds nothing and overwrites nothing. * test(native-chat): the settles-once fixture is an ordinary failed turn The fixture was labelled and worded as a failed compaction; this change covers the ordinary-turn path, so its frames now read as one. * test(native-chat): a failed Codex turn keeps its record when its two ends coalesce in the queue * fix(terminal): stop guessing that apps died and wiping their keyboard modes (#23584) * fix(terminal): stop guessing that apps died and wiping their keyboard modes The renderer wiped xterm's Kitty keyboard flags on every Ctrl+C, every live reattach, and every Windows agent turn end, though the app usually survives. xterm then encoded keys in legacy form while the pane mirror Orca's shortcut policy reads still held the negotiated flags, so Cmd+C, Shift+Enter, Option/Alt and IME commits disagreed with each other and with the app. - Delete the Ctrl+C wipe, the ConPTY agent-idle wipe, and the mirror reset on every PTY exit (it also ran on unverified host-loss exits). - Live reattach profiles no longer reset Kitty; every replay epilogue instead re-asserts the mirror's flags (pop-all, then the host-proven set; a bare pop while unproven), so a revealed xterm gets the live app's flags back. - Route every renderer-originated mode write through one scanning writer so xterm and the mirror always parse the same bytes: confirmed-shell reset, hibernate, cold restore, and a full process-boundary ground at fresh spawn. - Read Kitty flags as 0 where the protocol is withheld (ConPTY), since xterm ignores CSI u there but the mirror still scans it. - The dashboard popout restores snapshot flags as bytes so its xterm agrees. * style(terminal): separate the kitty restore builder from the pen reset * fix(terminal): let the kitty mirror own the withheld-protocol rule Review follow-ups for the stop-guessing change: - The mirror takes a `kittyKeyboard` option from the xterm's advertisement and ignores CSI u when withheld, as xterm does, replacing a per-reader helper that any new reader could skip. Daemon/headless users keep the default. - Replay epilogues are writers (`writeReplayEpilogue`, `writeReattachReplayReset`) that take the sync or async xterm writer, so nothing that looks like a builder mutates the mirror. - An abandoned hidden restore re-asserts the mirror's kitty flags after the byte-gap reset: its discarded chunks were already scanned. - Tests pin a non-zero host restore (epilogue ends `=31u`, mirror 31), a withheld pane staying at 0, and the restart-in-place ground landing after the mirror reset; the epilogue test helper is now an exact builder. * refactor(terminal): one epilogue writer and one scanned ground per boundary - Reattach callers write `chooseReattachReplayReset(...)` through `writeReplayEpilogue`, dropping the second writer from the session. - Fresh spawn and cold restore rely on their scanned ground alone: it leaves the mirror known at 0 with a proven baseline, so the extra reset() was dead. * feat(terminal): add Reset Terminal that grounds input modes on the host and the pane An app that crashes where the host sees no command end (no shell hooks, after exec bash, inside a manual ssh) keeps its Kitty, mouse, paste and focus modes. Reset Terminal grounds them in every model that can re-arm them: the pane's xterm and Kitty mirror, the daemon emulator, records and lifecycle scanner, the relay replay buffer, and main's headless model, so park/reveal, reattach and reload stay grounded. Each holder grounds its own model at the request, like Clear Screen. A zero-raw in-stream span would be dropped by SSH credit delivery and is ambiguous under snapshot-seq dedup. The host request is a sibling of clear, not a field on it, because an older host would ignore the field and clear scrollback instead. * test(e2e): Reset Terminal grounds an unhooked crash's modes through park and reveal * test(runtime): pin Reset Terminal's headless snapshot to a proven 0 kitty flag * fix(terminal): ground main's provider mode tracker in arrival order on Reset Terminal onPtyData scans live bytes into the tracker on arrival, so a ground queued on the headless write chain could land after a newer ?1049h and leave the tracker grounded while the emulator stays on the alternate screen. Also pass the serializer registry's clear/reset hooks as an options object. * fix(terminal): ground in-flight provider snapshot captures on Reset Terminal onPtyData feeds both the settled provider mode tracker and any in-flight capture's live tracker; the reset grounded only the first, so a capture whose request predated the reset could republish the pre-reset alternate screen. Share one scanProviderModeTrackers for both. Cover the pty:resetInputModes IPC handler and the runtime controller's host-window request. --------- Co-authored-by: GuanBear <123guan@gmail.com> Co-authored-by: guanbear <guanbear@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Harkaran Brar <18134082+harkaranbrar7@users.noreply.github.com> Co-authored-by: Prarambha369 <98906077+Prarambha369@users.noreply.github.com> Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: dalveytech-vincent <vincent@dalveytech.com> Co-authored-by: Eridanus117 <45489268+Eridanus117@users.noreply.github.com> Co-authored-by: xingqingzzp-gif <xingqingzzp-gif@users.noreply.github.com> Co-authored-by: jyang2004 <jyang2004@users.noreply.github.com> Co-authored-by: yunqian <yunqian@alibaba-inc.com> Co-authored-by: huzhening.hzn <huzhening.hzn@alibaba-inc.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
GuanBear
guanbear
Brennan Benson
Neil
Claude
Harkaran Brar
Prarambha369
Lesley Murfin
Jinjing
dalveytech-vincent
Eridanus117
xingqingzzp-gif
jyang2004
yunqian
huzhening.hzn
github-actions[bot]
parent
9b46c3f0f2
commit
1f6f8523ab
@@ -184,6 +184,11 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl {
|
||||
this.markSessionDirty(id)
|
||||
}
|
||||
|
||||
async resetInputModes(id: string): Promise<void> {
|
||||
await this.client.request('resetInputModes', { sessionId: id })
|
||||
this.markSessionDirty(id)
|
||||
}
|
||||
|
||||
acknowledgeDataEvent(_id: string, _charCount: number): void {
|
||||
// No flow control for daemon-backed terminals
|
||||
}
|
||||
|
||||
@@ -162,6 +162,10 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
await this.adapterFor(id).clearBuffer(id)
|
||||
}
|
||||
|
||||
async resetInputModes(id: string): Promise<void> {
|
||||
await this.adapterFor(id).resetInputModes(id)
|
||||
}
|
||||
|
||||
async closeStartupQueryAuthority(id: string): Promise<number> {
|
||||
return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@ export class DaemonRequestRouter {
|
||||
case 'clearScrollback':
|
||||
this.options.host.clearScrollback(request.payload.sessionId)
|
||||
return {}
|
||||
case 'resetInputModes':
|
||||
this.options.host.resetInputModes(request.payload.sessionId)
|
||||
return {}
|
||||
case 'listSessions':
|
||||
return { sessions: this.options.host.listSessions() }
|
||||
case 'shutdownIfIdle':
|
||||
|
||||
@@ -50,6 +50,7 @@ function createProvider(
|
||||
getCwd: vi.fn(async () => ''),
|
||||
getInitialCwd: vi.fn(async () => ''),
|
||||
clearBuffer: vi.fn(async () => {}),
|
||||
resetInputModes: vi.fn(async () => {}),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(async () => false),
|
||||
getForegroundProcess: vi.fn(async () => null),
|
||||
|
||||
@@ -168,6 +168,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
}
|
||||
|
||||
clearBuffer = (id: string): Promise<void> => this.providerFor(id).clearBuffer(id)
|
||||
resetInputModes = (id: string): Promise<void> => this.providerFor(id).resetInputModes(id)
|
||||
|
||||
async closeStartupQueryAuthority(id: string): Promise<number> {
|
||||
return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0
|
||||
|
||||
@@ -125,6 +125,16 @@ export class SessionOutputPlane {
|
||||
})
|
||||
}
|
||||
|
||||
/** Grounds the emulator and the cold-restore records without a client
|
||||
* broadcast; attached renderers ground themselves (Reset Terminal). */
|
||||
applyInputModeGround(ground: string): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.emulator.write(ground)
|
||||
this.record({ kind: 'output', data: ground })
|
||||
}
|
||||
|
||||
isCursorOnEmptyPromptLine(): boolean {
|
||||
return this.emulator.isCursorOnEmptyPromptLine()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Session } from './session'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
|
||||
function createSession() {
|
||||
let onData: ((data: string) => void) | null = null
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Session reads only these members.
|
||||
const handle = {
|
||||
pid: 999,
|
||||
getForegroundProcess: () => null,
|
||||
confirmShellForeground: vi.fn(async () => false),
|
||||
write: () => {},
|
||||
resize: () => {},
|
||||
pause: () => {},
|
||||
resume: () => {},
|
||||
kill: () => {},
|
||||
forceKill: () => {},
|
||||
signal: () => {},
|
||||
terminateOwnedTree: () => 'unavailable' as const,
|
||||
onData(cb: (data: string) => void) {
|
||||
onData = cb
|
||||
},
|
||||
onExit() {},
|
||||
dispose: () => {}
|
||||
} as unknown as SubprocessHandle
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the omitted options are optional.
|
||||
const session = new Session({
|
||||
sessionId: 'reset',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
subprocess: handle,
|
||||
shellReadySupported: false
|
||||
} as never)
|
||||
const received: string[] = []
|
||||
session.attachClient({ onData: (data: string) => received.push(data), onExit: () => {} })
|
||||
return { session, received, emit: (data: string) => onData?.(data) }
|
||||
}
|
||||
|
||||
async function readModes(session: Session) {
|
||||
await session.settleShellOwnershipConfirmation()
|
||||
return session.getSnapshot()!.modes
|
||||
}
|
||||
|
||||
describe('Session.resetInputModes', () => {
|
||||
it('grounds modes an app left armed without a command end, and the next snapshot carries it', async () => {
|
||||
const { session, received, emit } = createSession()
|
||||
// A crashed app's arming with no OSC 133;D the barrier could ground at.
|
||||
emit('prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?1h')
|
||||
const armed = await readModes(session)
|
||||
expect(armed).toMatchObject({
|
||||
kittyKeyboardFlags: 31,
|
||||
mouseTracking: true,
|
||||
bracketedPaste: true
|
||||
})
|
||||
session.takePendingOutput(false)
|
||||
const broadcast = received.length
|
||||
|
||||
session.resetInputModes()
|
||||
|
||||
expect(await readModes(session)).toMatchObject({
|
||||
kittyKeyboardFlags: 0,
|
||||
mouseTracking: false,
|
||||
sgrMouseMode: false,
|
||||
bracketedPaste: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
})
|
||||
// Clients ground themselves; a zero-raw span here would be dropped or duplicated.
|
||||
expect(received).toHaveLength(broadcast)
|
||||
const records = session.takePendingOutput(false)!.records
|
||||
expect(records).toEqual([{ kind: 'output', data: expect.stringContaining('\x1b[<99u') }])
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('keeps focus reporting the terminal host armed for the pane', async () => {
|
||||
const { session, emit } = createSession()
|
||||
// ConPTY arms ?1004h before any shell marker.
|
||||
emit('\x1b[?1004hprompt$ ')
|
||||
await readModes(session)
|
||||
session.takePendingOutput(false)
|
||||
|
||||
session.resetInputModes()
|
||||
|
||||
const [record] = session.takePendingOutput(false)!.records
|
||||
expect(record).toMatchObject({ kind: 'output' })
|
||||
expect(record?.kind === 'output' && record.data).not.toContain('\x1b[?1004l')
|
||||
session.dispose()
|
||||
})
|
||||
})
|
||||
@@ -146,13 +146,9 @@ export class Session {
|
||||
|
||||
// Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137).
|
||||
// DA1/CPR stay immediate unless an echo-risk reply is already held (#13892, #15559).
|
||||
if (this.startupIngress.answerLiveQueryReply(data)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: keep queuing during the post-ready flush-gate window ('ready' but not yet flushed); a
|
||||
// direct write would race fresh input ahead of the buffered startup command.
|
||||
if (this.shellReady.tryEnqueue(data)) {
|
||||
// Why the queue: keep queuing during the post-ready flush-gate window ('ready' but not yet
|
||||
// flushed); a direct write would race fresh input ahead of the buffered startup command.
|
||||
if (this.startupIngress.answerLiveQueryReply(data) || this.shellReady.tryEnqueue(data)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,6 +273,10 @@ export class Session {
|
||||
this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites)
|
||||
}
|
||||
|
||||
resetInputModes(): void {
|
||||
this.output.applyInputModeGround(this.recoveryBarrier.groundInputModes())
|
||||
}
|
||||
|
||||
prepareForFinalSnapshot(): string {
|
||||
const held = this.shellReady.releaseHeldBytes()
|
||||
this.startupIngress.snapshotBarrier()
|
||||
|
||||
@@ -277,6 +277,10 @@ export class TerminalHost {
|
||||
getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback()
|
||||
}
|
||||
|
||||
resetInputModes(sessionId: string): void {
|
||||
getAliveTerminalHostSession(this.sessions, sessionId).resetInputModes()
|
||||
}
|
||||
|
||||
// Why: null-not-throw — checkpoint is best-effort against a session that may have just exited.
|
||||
getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
|
||||
return getTerminalHostSnapshot(this.sessions.get(sessionId), opts)
|
||||
|
||||
@@ -86,6 +86,13 @@ export class TerminalShellRecoveryBarrier {
|
||||
return this.scanner.owner
|
||||
}
|
||||
|
||||
/** Reset Terminal: grounds the lifecycle model now and returns the bytes for
|
||||
* the host's other models. Not released downstream: a zero-raw span is dropped
|
||||
* by credit-windowed delivery and snapshot-seq dedup, so each client grounds itself. */
|
||||
groundInputModes(): string {
|
||||
return this.scanner.groundProcessBoundary()
|
||||
}
|
||||
|
||||
/** Answers a paired runtime's ownership question from the barrier's settled
|
||||
* state. The barrier scans bytes before any consumer receives them, so its
|
||||
* verdict is never behind the caller's parse position — a fresh process
|
||||
|
||||
@@ -206,9 +206,10 @@ export type GetCwdRequest = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ClearScrollbackRequest = {
|
||||
// Why resetInputModes is a type, not a clear flag: an older daemon rejects it instead of clearing.
|
||||
export type TerminalBufferActionRequest = {
|
||||
id: string
|
||||
type: 'clearScrollback'
|
||||
type: 'clearScrollback' | 'resetInputModes'
|
||||
payload: {
|
||||
sessionId: string
|
||||
}
|
||||
@@ -321,7 +322,7 @@ export type DaemonRequest =
|
||||
| InspectProcessRequest
|
||||
| ConfirmForegroundProcessRequest
|
||||
| ConfirmShellForegroundRequest
|
||||
| ClearScrollbackRequest
|
||||
| TerminalBufferActionRequest
|
||||
| ShutdownRequest
|
||||
| PingRequest
|
||||
| SystemResolverHealthRequest
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { onMock } from './pty-ipc-mock-registry'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { registerPtyHandlers } from './pty'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
describe('Reset Terminal main-side entry points', () => {
|
||||
const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite()
|
||||
|
||||
function setup() {
|
||||
const resetInputModes = vi.fn(async () => {})
|
||||
installDaemonTestProvider({ resetInputModes })
|
||||
let controller: { resetInputModes: (ptyId: string) => Promise<void> } | undefined
|
||||
const runtime = {
|
||||
setPtyController: vi.fn((next) => {
|
||||
controller = next
|
||||
}),
|
||||
resetHeadlessTerminalInputModes: vi.fn(async () => {})
|
||||
}
|
||||
handlers.clear()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only these members.
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
return { resetInputModes, runtime, controller: controller! }
|
||||
}
|
||||
|
||||
it('grounds the provider and the headless model from the pane IPC', () => {
|
||||
const { resetInputModes, runtime } = setup()
|
||||
const listener = onMock.mock.calls.findLast(
|
||||
(entry: unknown[]) => entry[0] === 'pty:resetInputModes'
|
||||
)?.[1]
|
||||
if (typeof listener !== 'function') {
|
||||
throw new Error('missing pty:resetInputModes listener')
|
||||
}
|
||||
|
||||
listener(null, { id: 'pty-1' })
|
||||
|
||||
expect(resetInputModes).toHaveBeenCalledWith('pty-1')
|
||||
expect(runtime.resetHeadlessTerminalInputModes).toHaveBeenCalledWith('pty-1')
|
||||
// The pane grounded itself before sending; echoing back would ground it twice.
|
||||
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith(
|
||||
'pty:resetInputModes:request',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it("grounds the host window's pane and the provider for a runtime-initiated reset", async () => {
|
||||
const { resetInputModes, controller } = setup()
|
||||
|
||||
await controller.resetInputModes('pty-1')
|
||||
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:resetInputModes:request', {
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
expect(resetInputModes).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('swallows an older host rejecting the request', async () => {
|
||||
const { resetInputModes, controller } = setup()
|
||||
resetInputModes.mockRejectedValueOnce(new Error('Unknown request type: resetInputModes'))
|
||||
|
||||
await expect(controller.resetInputModes('pty-1')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -322,4 +322,13 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void {
|
||||
.catch(() => {})
|
||||
runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:resetInputModes')
|
||||
ipcMain.on('pty:resetInputModes', (_event, args: { id: string }) => {
|
||||
// Why: an older daemon or relay rejects the request; its model keeps the modes until reattach.
|
||||
tryGetProviderForPty(args.id)
|
||||
?.resetInputModes(args.id)
|
||||
.catch(() => {})
|
||||
runtime?.resetHeadlessTerminalInputModes(args.id).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
attachPtyFromRuntimeController,
|
||||
clearBufferFromRuntimeController,
|
||||
resetInputModesFromRuntimeController,
|
||||
confirmForegroundProcessFromRuntimeController,
|
||||
confirmShellForegroundFromRuntimeController,
|
||||
getCwdFromRuntimeController,
|
||||
@@ -70,6 +71,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
|
||||
getCwd: (ptyId) => getCwdFromRuntimeController(ptyId),
|
||||
hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId),
|
||||
clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId),
|
||||
resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId),
|
||||
hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId),
|
||||
listProcesses: (connectionId, opts) =>
|
||||
listProcessesFromRuntimeController(deps, connectionId, opts),
|
||||
|
||||
@@ -193,6 +193,21 @@ export async function clearBufferFromRuntimeController(
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetInputModesFromRuntimeController(
|
||||
deps: PtyRuntimeControllerDeps,
|
||||
ptyId: string
|
||||
): Promise<void> {
|
||||
// Why: a remote client's reset must also ground this host window's view of the pane.
|
||||
if (deps.mainWindow && !deps.mainWindow.isDestroyed()) {
|
||||
deps.mainWindow.webContents.send('pty:resetInputModes:request', { ptyId })
|
||||
}
|
||||
try {
|
||||
await getProviderForPty(ptyId).resetInputModes(ptyId)
|
||||
} catch {
|
||||
/* best effort: an older daemon or relay rejects the request */
|
||||
}
|
||||
}
|
||||
|
||||
const settledLocalPtyProviderStartups = new WeakSet<Promise<void>>()
|
||||
const watchedLocalPtyProviderStartups = new WeakSet<Promise<void>>()
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
clearBuffer(id: string): Promise<void> {
|
||||
return clearLocalPtyBuffer(id)
|
||||
}
|
||||
// A direct PTY keeps no terminal model of its own.
|
||||
async resetInputModes(_id: string): Promise<void> {}
|
||||
closeStartupQueryAuthority(id: string): number {
|
||||
return closeLocalPtyStartupQueryAuthority(id)
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ describe('PTY provider dispatch', () => {
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
resetInputModes: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
|
||||
@@ -224,6 +224,8 @@ export type IPtyProvider = {
|
||||
getCwd(id: string): Promise<string>
|
||||
getInitialCwd(id: string): Promise<string>
|
||||
clearBuffer(id: string): Promise<void>
|
||||
/** Grounds the host's own terminal models (Reset Terminal); renderers ground themselves. */
|
||||
resetInputModes(id: string): Promise<void>
|
||||
/** Ordered handoff from startup source authority to the live/hidden view authority. */
|
||||
closeStartupQueryAuthority?: (id: string) => Promise<number> | number
|
||||
acknowledgeDataEvent(id: string, charCount: number): void
|
||||
|
||||
@@ -34,6 +34,9 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP
|
||||
clearBuffer: async (id: string): Promise<void> => {
|
||||
await mux.request('pty.clearBuffer', { id: toRelayPtyId(id) })
|
||||
},
|
||||
resetInputModes: async (id: string): Promise<void> => {
|
||||
await mux.request('pty.resetInputModes', { id: toRelayPtyId(id) })
|
||||
},
|
||||
closeStartupQueryAuthority: async (id: string): Promise<number> => {
|
||||
const result = (await mux.request('pty.closeStartupQueryAuthority', {
|
||||
id: toRelayPtyId(id)
|
||||
|
||||
@@ -55,6 +55,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
getCwd = (id: string): Promise<string> => this.rpcOperations.getCwd(id)
|
||||
getInitialCwd = (id: string): Promise<string> => this.rpcOperations.getInitialCwd(id)
|
||||
clearBuffer = (id: string): Promise<void> => this.rpcOperations.clearBuffer(id)
|
||||
resetInputModes = (id: string): Promise<void> => this.rpcOperations.resetInputModes(id)
|
||||
closeStartupQueryAuthority = (id: string): Promise<number> =>
|
||||
this.rpcOperations.closeStartupQueryAuthority(id)
|
||||
acknowledgeDataEvent = (id: string, charCount: number): void =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-que
|
||||
import { isNativeWindowsConptyPty } from './terminal-model-query-authority'
|
||||
import { getTerminalViewAttributes } from './terminal-view-attribute-store'
|
||||
import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror'
|
||||
import { PROCESS_BOUNDARY_GROUND } from '../../shared/terminal-mode-reset-profiles'
|
||||
|
||||
export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer {
|
||||
/** Shared factory for the per-PTY runtime emulators (seed, hydration, and
|
||||
@@ -195,4 +196,21 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi
|
||||
state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback())
|
||||
await state.writeChain
|
||||
}
|
||||
|
||||
// Public: Reset Terminal must ground this model too; park/reveal and mobile restore from it.
|
||||
async resetHeadlessTerminalInputModes(ptyId: string): Promise<void> {
|
||||
// Why now, not on the chain: onPtyData scans live bytes into these on arrival.
|
||||
// Focus is outside their model, so the plain ground is exact.
|
||||
this.scanProviderModeTrackers(ptyId, PROCESS_BOUNDARY_GROUND)
|
||||
const state = this.headlessTerminals.get(ptyId)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
// Why on the chain: the ground must land after every PTY chunk already queued.
|
||||
const completion = state.writeChain.then(async () => {
|
||||
await state.emulator.write(state.ownership.groundInputModes())
|
||||
})
|
||||
state.writeChain = completion.catch(() => {})
|
||||
await completion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
|
||||
export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecutionContext {
|
||||
/** Arrival-order mode scan: the settled tracker plus any in-flight snapshot capture's. */
|
||||
protected scanProviderModeTrackers(ptyId: string, data: string): void {
|
||||
this.providerModeTrackersByPtyId.get(ptyId)?.scan(data)
|
||||
for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) {
|
||||
tracker.scan(data)
|
||||
}
|
||||
}
|
||||
|
||||
onPtyData(
|
||||
ptyId: string,
|
||||
data: string,
|
||||
@@ -27,10 +35,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution
|
||||
): number {
|
||||
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars
|
||||
this.ptyOutputSequenceById.set(ptyId, outputSequence)
|
||||
this.providerModeTrackersByPtyId.get(ptyId)?.scan(data)
|
||||
for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) {
|
||||
tracker.scan(data)
|
||||
}
|
||||
this.scanProviderModeTrackers(ptyId, data)
|
||||
const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data)
|
||||
const cwd = osc7Metadata.cwd
|
||||
const cwdChanged = osc7Metadata.cwdChanged
|
||||
|
||||
@@ -80,6 +80,16 @@ export class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRuntimeWithA
|
||||
return { handle, cleared: true }
|
||||
}
|
||||
|
||||
async resetTerminalInputModes(handle: string): Promise<{ handle: string; reset: boolean }> {
|
||||
const leaf = this.resolveLeafForHandle(handle)
|
||||
if (!leaf?.ptyId) {
|
||||
throw new Error('terminal_not_found')
|
||||
}
|
||||
await this.ptyController?.resetInputModes?.(leaf.ptyId)
|
||||
await this.resetHeadlessTerminalInputModes(leaf.ptyId)
|
||||
return { handle, reset: true }
|
||||
}
|
||||
|
||||
getTerminalSize(ptyId: string): { cols: number; rows: number } | null {
|
||||
return this.ptyController?.getSize?.(ptyId) ?? null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
|
||||
import type { PtyProviderBufferSnapshot } from '../../providers/pty-provider-contract'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
@@ -199,6 +201,79 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(snapshot?.data).not.toContain('line-0')
|
||||
})
|
||||
|
||||
it('resets input modes through the PTY controller and the headless model', async () => {
|
||||
const resetInputModes = vi.fn().mockResolvedValue(undefined)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
resetInputModes
|
||||
})
|
||||
syncSinglePty(runtime, 'pty-1')
|
||||
// An app armed these and crashed with no command end.
|
||||
runtime.onPtyData('pty-1', 'prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?2004h', 123)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const armed = await runtime.serializeTerminalBuffer('pty-1')
|
||||
expect(armed?.kittyKeyboardFlags).toBe(31)
|
||||
|
||||
await expect(runtime.resetTerminalInputModes(terminal.handle)).resolves.toEqual({
|
||||
handle: terminal.handle,
|
||||
reset: true
|
||||
})
|
||||
|
||||
expect(resetInputModes).toHaveBeenCalledWith('pty-1')
|
||||
const snapshot = await runtime.serializeTerminalBuffer('pty-1')
|
||||
expect(snapshot?.kittyKeyboardFlags).toBe(0)
|
||||
expect(snapshot?.data).not.toContain('\x1b[?1000h')
|
||||
expect(snapshot?.data).not.toContain('\x1b[?2004h')
|
||||
})
|
||||
|
||||
it('grounds the provider mode tracker before a later chunk, in the emulator order', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime, 'pty-1')
|
||||
runtime.onPtyData('pty-1', 'prompt$ ', 123)
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
runtime['providerModeTrackersByPtyId'].set('pty-1', tracker)
|
||||
|
||||
// A TUI starts while the reset still waits on the headless write chain.
|
||||
const reset = runtime.resetHeadlessTerminalInputModes('pty-1')
|
||||
runtime.onPtyData('pty-1', '\x1b[?1049h', 124)
|
||||
await reset
|
||||
await runtime['headlessTerminals'].get('pty-1')?.writeChain
|
||||
|
||||
expect(runtime['headlessTerminals'].get('pty-1')?.emulator.isAlternateScreen).toBe(true)
|
||||
expect(tracker.isAlternateScreen).toBe(true)
|
||||
})
|
||||
|
||||
it('grounds an in-flight provider snapshot capture so it cannot publish the pre-reset screen', async () => {
|
||||
let resolveSnapshot: (snapshot: PtyProviderBufferSnapshot) => void = () => {}
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
serializeProviderBuffer: () => new Promise((resolve) => (resolveSnapshot = resolve))
|
||||
})
|
||||
syncSinglePty(runtime, 'pty-1')
|
||||
const generation = runtime['getPtyLifecycleGeneration']('pty-1')
|
||||
|
||||
// The capture's daemon request left before the reset; its answer predates the ground.
|
||||
const capture = runtime['captureProviderTerminalBuffer']('pty-1', {}, generation)
|
||||
await runtime.resetHeadlessTerminalInputModes('pty-1')
|
||||
resolveSnapshot({
|
||||
data: '',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 1,
|
||||
source: 'headless',
|
||||
alternateScreen: true
|
||||
})
|
||||
await capture
|
||||
|
||||
expect(runtime['providerModeTrackersByPtyId'].get('pty-1')?.isAlternateScreen).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for terminal exit and resolves with the exit status', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ export class PtyShellOwnershipMirror {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset Terminal: the ground for this mirror's view of mode ownership, already scanned. */
|
||||
groundInputModes(): string {
|
||||
return this.scanner.groundProcessBoundary()
|
||||
}
|
||||
|
||||
get owner(): TerminalOwner | undefined {
|
||||
return this.scanner.owner
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [
|
||||
['terminal.agentStatus', { terminal: 'term' }, false],
|
||||
['terminal.rename', { terminal: 'term', title: null }, false],
|
||||
['terminal.clearBuffer', { terminal: 'term' }, false],
|
||||
['terminal.resetInputModes', { terminal: 'term' }, false],
|
||||
['terminal.send', { terminal: 'term', text: 'x' }, false],
|
||||
['terminal.wait', { terminal: 'term', for: 'exit' }, false],
|
||||
['terminal.create', {}, false],
|
||||
@@ -67,11 +68,11 @@ async function invoke(name: string, params: unknown, runtime: Partial<OrcaRuntim
|
||||
|
||||
describe('terminal RPC manifest characterization', () => {
|
||||
it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => {
|
||||
expect(TERMINAL_METHODS).toHaveLength(35)
|
||||
expect(TERMINAL_METHODS).toHaveLength(36)
|
||||
expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual(
|
||||
METHOD_CASES.map(([name, _params, stream]) => [name, stream])
|
||||
)
|
||||
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(35)
|
||||
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(36)
|
||||
for (const [name, params] of METHOD_CASES) {
|
||||
expect(() => schemaFor(name).parse(params), name).not.toThrow()
|
||||
}
|
||||
|
||||
@@ -121,5 +121,12 @@ export const TERMINAL_QUERY_METHODS = [
|
||||
handler: async (params, { runtime }) => ({
|
||||
clear: await runtime.clearTerminalBuffer(params.terminal)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.resetInputModes',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
reset: await runtime.resetTerminalInputModes(params.terminal)
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -124,6 +124,7 @@ export type RuntimePtyController = {
|
||||
confirmShellForeground?(ptyId: string): Promise<boolean>
|
||||
hasChildProcesses?(ptyId: string): Promise<boolean>
|
||||
clearBuffer?(ptyId: string): Promise<void>
|
||||
resetInputModes?(ptyId: string): Promise<void>
|
||||
resize?(ptyId: string, cols: number, rows: number): boolean
|
||||
// Why: exact-id mobile polls should not enumerate every local and SSH PTY.
|
||||
hasPty?(ptyId: string): boolean | null
|
||||
|
||||
@@ -84,6 +84,7 @@ export type PtyApi = {
|
||||
reportGeometry: (id: string, cols: number, rows: number) => void
|
||||
signal: (id: string, signal: string) => void
|
||||
clearBuffer: (id: string) => void
|
||||
resetInputModes: (id: string) => void
|
||||
kill: (id: string, opts?: { keepHistory?: boolean }) => Promise<void>
|
||||
ackColdRestore: (id: string) => void
|
||||
ackData: (id: string, charCount: number, processedChars?: number) => void
|
||||
@@ -229,6 +230,7 @@ export type PtyApi = {
|
||||
}) => void
|
||||
) => () => void
|
||||
onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void
|
||||
onResetInputModesRequest: (callback: (data: { ptyId: string }) => void) => () => void
|
||||
sendSerializedBuffer: (
|
||||
requestId: string,
|
||||
snapshot: {
|
||||
|
||||
@@ -98,6 +98,9 @@ export const ptySessionControlApi = {
|
||||
clearBuffer: (id: string): void => {
|
||||
ipcRenderer.send('pty:clearBuffer', { id })
|
||||
},
|
||||
resetInputModes: (id: string): void => {
|
||||
ipcRenderer.send('pty:resetInputModes', { id })
|
||||
},
|
||||
ackColdRestore: (id: string): void => {
|
||||
ipcRenderer.send('pty:ackColdRestore', { id })
|
||||
},
|
||||
|
||||
@@ -120,6 +120,11 @@ export const ptyStreamAndSerializationApi = {
|
||||
ipcRenderer.on('pty:clearBuffer:request', listener)
|
||||
return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener)
|
||||
},
|
||||
onResetInputModesRequest: (callback: (data: { ptyId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data)
|
||||
ipcRenderer.on('pty:resetInputModes:request', listener)
|
||||
return () => ipcRenderer.removeListener('pty:resetInputModes:request', listener)
|
||||
},
|
||||
sendSerializedBuffer: (
|
||||
requestId: string,
|
||||
snapshot: {
|
||||
|
||||
@@ -193,4 +193,17 @@ describe.each([
|
||||
|
||||
expect(published()).toBe(`${DYING_COMMAND}${COMMAND_DONE}${PROMPT}`)
|
||||
})
|
||||
|
||||
it('grounds replay, not the live stream, on Reset Terminal after an unhooked crash', async () => {
|
||||
// Armed with no command end: nothing the host barrier could ground at.
|
||||
const unhookedCrash = '\x1b[>1u\x1b[?1000h'
|
||||
await stream(unhookedCrash, PROMPT)
|
||||
|
||||
dispatcher.feed(requestFrame(4, 'pty.resetInputModes', { id: ptyId }))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
// The client grounds its own view; a zero-raw span would not cross the credit window.
|
||||
expect(published()).toBe(`${unhookedCrash}${PROMPT}`)
|
||||
expect(await replay()).toBe(`${unhookedCrash}${PROMPT}${PROCESS_BOUNDARY_GROUND}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1118,6 +1118,7 @@ export class PtyHandler {
|
||||
this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p))
|
||||
this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p))
|
||||
this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p))
|
||||
this.dispatcher.onRequest('pty.resetInputModes', (p) => this.resetInputModes(p))
|
||||
this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p))
|
||||
this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p))
|
||||
this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p))
|
||||
@@ -2686,6 +2687,15 @@ export class PtyHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Why the replay buffer and not the stream: a zero-raw span never crosses the
|
||||
// credit window, and the client grounds its own view; reattach replays this.
|
||||
private async resetInputModes(params: Record<string, unknown>): Promise<void> {
|
||||
const managed = this.ptys.get(params.id as string)
|
||||
if (managed?.recoveryBarrier && !managed.disposed) {
|
||||
this.appendReplayBuffer(managed, managed.recoveryBarrier.groundInputModes())
|
||||
}
|
||||
}
|
||||
|
||||
private async hasChildProcesses(params: Record<string, unknown>): Promise<boolean> {
|
||||
const id = params.id as string
|
||||
const managed = this.ptys.get(id)
|
||||
|
||||
@@ -78,6 +78,7 @@ function renderMenu(overrides: Record<string, unknown> = {}): string {
|
||||
onEqualizePaneSizes: vi.fn(),
|
||||
onClosePane: vi.fn(),
|
||||
onClearScreen: vi.fn(),
|
||||
onResetTerminal: vi.fn(),
|
||||
canContinueAgentSessionInNewSession: false,
|
||||
onContinueAgentSessionInNewSession: vi.fn(),
|
||||
onForkAgentSession: vi.fn(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PanelsTopLeft,
|
||||
PanelRightClose,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
SquareTerminal,
|
||||
TextSelect,
|
||||
X
|
||||
@@ -53,6 +54,7 @@ type TerminalContextMenuProps = {
|
||||
onEqualizePaneSizes: () => void
|
||||
onClosePane: () => void
|
||||
onClearScreen: () => void
|
||||
onResetTerminal: () => void
|
||||
canContinueAgentSessionInNewSession: boolean
|
||||
onContinueAgentSessionInNewSession: () => void
|
||||
onForkAgentSession: () => void
|
||||
@@ -144,6 +146,7 @@ function TerminalContextMenuItems({
|
||||
onEqualizePaneSizes,
|
||||
onClosePane,
|
||||
onClearScreen,
|
||||
onResetTerminal,
|
||||
canContinueAgentSessionInNewSession,
|
||||
onContinueAgentSessionInNewSession,
|
||||
onForkAgentSession,
|
||||
@@ -350,6 +353,13 @@ function TerminalContextMenuItems({
|
||||
<Eraser />
|
||||
{translate('auto.components.terminal.pane.TerminalContextMenu.b4cdd9314e', 'Clear Screen')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onResetTerminal}>
|
||||
<RotateCcw />
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalContextMenu.resetTerminal',
|
||||
'Reset Terminal'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ export function TerminalPaneSurface({
|
||||
onEqualizePaneSizes={contextMenu.onEqualizePaneSizes}
|
||||
onClosePane={contextMenu.onClosePane}
|
||||
onClearScreen={contextMenu.onClearScreen}
|
||||
onResetTerminal={contextMenu.onResetTerminal}
|
||||
canContinueAgentSessionInNewSession={contextMenuCanContinueInNewSession}
|
||||
onContinueAgentSessionInNewSession={contextMenu.onContinueAgentSessionInNewSession}
|
||||
onForkAgentSession={() => void contextMenu.onForkAgentSession()}
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('pty buffer serializer registry', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only these pty members.
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
@@ -13,6 +14,7 @@ describe('pty buffer serializer registry', () => {
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
onClearBufferRequest: vi.fn(() => () => {}),
|
||||
onResetInputModesRequest: vi.fn(() => () => {}),
|
||||
onSerializeBufferRequest: vi.fn(() => () => {}),
|
||||
sendSerializedBuffer: vi.fn()
|
||||
}
|
||||
@@ -120,4 +122,16 @@ describe('pty buffer serializer registry', () => {
|
||||
expect.objectContaining({ pendingEscapeTailAnsi: '\x1b[38;5;' })
|
||||
)
|
||||
})
|
||||
|
||||
it("routes a host's Reset Terminal request to the pane that owns the PTY", async () => {
|
||||
const { registerPtySerializer } = await import('./pty-buffer-serializer')
|
||||
const resetInputModes = vi.fn()
|
||||
registerPtySerializer('pty-1', () => null, { resetInputModes })
|
||||
const [[onRequest]] = vi.mocked(window.api.pty.onResetInputModesRequest).mock.calls
|
||||
|
||||
onRequest({ ptyId: 'pty-2' })
|
||||
expect(resetInputModes).not.toHaveBeenCalled()
|
||||
onRequest({ ptyId: 'pty-1' })
|
||||
expect(resetInputModes).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ export type SerializeFn = (
|
||||
type SerializerEntry = {
|
||||
fn: SerializeFn
|
||||
clear?: () => void
|
||||
resetInputModes?: () => void
|
||||
owner: symbol
|
||||
}
|
||||
|
||||
@@ -50,10 +51,10 @@ let listenerAttached = false
|
||||
export function registerPtySerializer(
|
||||
ptyId: string,
|
||||
serialize: SerializeFn,
|
||||
clear?: () => void
|
||||
actions: Pick<SerializerEntry, 'clear' | 'resetInputModes'> = {}
|
||||
): () => void {
|
||||
const owner = Symbol(ptyId)
|
||||
serializersByPtyId.set(ptyId, { fn: serialize, clear, owner })
|
||||
serializersByPtyId.set(ptyId, { fn: serialize, ...actions, owner })
|
||||
ensureSerializerListener()
|
||||
return () => {
|
||||
const current = serializersByPtyId.get(ptyId)
|
||||
@@ -116,6 +117,11 @@ export function registerPtyTitleSource(
|
||||
}
|
||||
}
|
||||
|
||||
/** Grounds the pane's own records only; the host grounds its models on its own request. */
|
||||
export function resetPtyRendererInputModes(ptyId: string): void {
|
||||
serializersByPtyId.get(ptyId)?.resetInputModes?.()
|
||||
}
|
||||
|
||||
export function hasPtySerializer(ptyId: string): boolean {
|
||||
return serializersByPtyId.has(ptyId)
|
||||
}
|
||||
@@ -133,6 +139,8 @@ function ensureSerializerListener(): void {
|
||||
serializersByPtyId.get(request.ptyId)?.clear?.()
|
||||
})
|
||||
|
||||
window.api.pty.onResetInputModesRequest((request) => resetPtyRendererInputModes(request.ptyId))
|
||||
|
||||
window.api.pty.onSerializeBufferRequest((request) => {
|
||||
const entry = serializersByPtyId.get(request.ptyId)
|
||||
void Promise.resolve(entry?.fn(request.opts) ?? null)
|
||||
|
||||
+15
@@ -215,4 +215,19 @@ describe('connectPanePty kitty keyboard restore', () => {
|
||||
expect(mirror.snapshotFlags).toBe(0)
|
||||
expect(mirror.isAlternateScreen).toBe(false)
|
||||
})
|
||||
|
||||
it('grounds xterm and the mirror together on Reset Terminal and asks the host to ground', async () => {
|
||||
const { pane, mirror } = await reattachWithSnapshotFlags(true)
|
||||
expect(mirror?.flags).toBe(31)
|
||||
const { resetTerminalInputModes } = await import('./terminal-input-mode-reset')
|
||||
|
||||
resetTerminalInputModes('tab-pty')
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith(
|
||||
PROCESS_BOUNDARY_GROUND,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mirror?.flags).toBe(0)
|
||||
expect(window.api.pty.resetInputModes).toHaveBeenCalledWith('tab-pty')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,8 @@ export function installTerminalTestGlobals(): void {
|
||||
setPtyDeliveryInterest: vi.fn(),
|
||||
ackColdRestore: vi.fn(),
|
||||
onClearBufferRequest: vi.fn(() => vi.fn()),
|
||||
onResetInputModesRequest: vi.fn(() => vi.fn()),
|
||||
resetInputModes: vi.fn(),
|
||||
onSerializeBufferRequest: vi.fn(() => vi.fn()),
|
||||
sendSerializedBuffer: vi.fn(),
|
||||
declarePendingPaneSerializer: vi.fn().mockResolvedValue(1),
|
||||
|
||||
+11
-4
@@ -1,3 +1,4 @@
|
||||
import { buildProcessBoundaryGround } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { serializeWithAbsoluteCursor } from '../../../../../shared/terminal-serialize-absolute-cursor'
|
||||
import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health'
|
||||
import { registerPtySerializer, registerPtyTitleSource } from '../pty-buffer-serializer'
|
||||
@@ -72,10 +73,16 @@ export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void
|
||||
return null
|
||||
}
|
||||
},
|
||||
() => {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
discardTerminalOutput(session.pane.terminal)
|
||||
clearTerminalScrollbackAndFollowOutput(session.pane.terminal)
|
||||
{
|
||||
clear: () => {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
discardTerminalOutput(session.pane.terminal)
|
||||
clearTerminalScrollbackAndFollowOutput(session.pane.terminal)
|
||||
},
|
||||
resetInputModes: () =>
|
||||
session.writeInputModeGround(
|
||||
buildProcessBoundaryGround({ keepFocusReporting: session.isNativeWindowsConpty })
|
||||
)
|
||||
}
|
||||
)
|
||||
const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) =>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { resetWebRuntimeTerminalInputModes } from '@/runtime/web-runtime-session'
|
||||
import { resetPtyRendererInputModes } from './pty-buffer-serializer'
|
||||
|
||||
/**
|
||||
* Reset Terminal: grounds the input modes an app left armed (Kitty keyboard,
|
||||
* mouse, bracketed paste, focus, alt screen, cursor keys) in the pane and in the
|
||||
* host's models, so a reattach does not re-arm them. Each side grounds its own
|
||||
* records; an older host rejects the request and only the pane is grounded.
|
||||
*/
|
||||
export function resetTerminalInputModes(ptyId: string | null): void {
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
resetPtyRendererInputModes(ptyId)
|
||||
if (!resetWebRuntimeTerminalInputModes(ptyId)) {
|
||||
window.api.pty.resetInputModes(ptyId)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { useTerminalContextMenuTrigger } from './use-terminal-context-menu-trigg
|
||||
import { useAppStore } from '@/store'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { resolvePaneAgentSessionId } from './pane-agent-session-id'
|
||||
import { resetTerminalInputModes } from './terminal-input-mode-reset'
|
||||
|
||||
type UseTerminalPaneContextMenuDeps = {
|
||||
managerRef: React.RefObject<PaneManager | null>
|
||||
@@ -68,6 +69,7 @@ type TerminalMenuState = {
|
||||
onEqualizePaneSizes: () => void
|
||||
onClosePane: () => void
|
||||
onClearScreen: () => void
|
||||
onResetTerminal: () => void
|
||||
onForkAgentSession: () => Promise<void>
|
||||
onContinueAgentSessionInNewSession: () => void
|
||||
onCopyAgentSessionContext: () => Promise<void>
|
||||
@@ -209,6 +211,13 @@ export function useTerminalPaneContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
const onResetTerminal = (): void => {
|
||||
const pane = resolveMenuPane()
|
||||
if (pane) {
|
||||
resetTerminalInputModes(paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
const onForkAgentSession = async (): Promise<void> =>
|
||||
forkAgentSessionFromMenuPane(agentSessionContext, resolveMenuPane())
|
||||
|
||||
@@ -295,6 +304,7 @@ export function useTerminalPaneContextMenu({
|
||||
onEqualizePaneSizes,
|
||||
onClosePane,
|
||||
onClearScreen,
|
||||
onResetTerminal,
|
||||
onForkAgentSession,
|
||||
onContinueAgentSessionInNewSession,
|
||||
onCopyAgentSessionContext,
|
||||
|
||||
@@ -3193,6 +3193,7 @@
|
||||
},
|
||||
"TerminalContextMenu": {
|
||||
"b4cdd9314e": "Clear Screen",
|
||||
"resetTerminal": "Reset Terminal",
|
||||
"8c17d6786d": "Close Pane",
|
||||
"copyTerminalId": "Copy Terminal ID",
|
||||
"2cf85a6a55": "Copy Pane ID",
|
||||
|
||||
@@ -2751,6 +2751,7 @@
|
||||
},
|
||||
"TerminalContextMenu": {
|
||||
"b4cdd9314e": "Borrar pantalla",
|
||||
"resetTerminal": "Restablecer terminal",
|
||||
"8c17d6786d": "Cerrar panel",
|
||||
"copyTerminalId": "Copiar ID de terminal",
|
||||
"2cf85a6a55": "Copiar ID del panel",
|
||||
|
||||
@@ -3136,6 +3136,7 @@
|
||||
},
|
||||
"TerminalContextMenu": {
|
||||
"b4cdd9314e": "Effacer l'écran",
|
||||
"resetTerminal": "Réinitialiser le terminal",
|
||||
"8c17d6786d": "Fermer le volet",
|
||||
"copyTerminalId": "Copier l'ID du terminal",
|
||||
"2cf85a6a55": "Copier l'ID du volet",
|
||||
|
||||
@@ -3033,6 +3033,7 @@
|
||||
},
|
||||
"TerminalContextMenu": {
|
||||
"b4cdd9314e": "クリアスクリーン",
|
||||
"resetTerminal": "ターミナルをリセット",
|
||||
"8c17d6786d": "ペインを閉じる",
|
||||
"copyTerminalId": "ターミナル ID をコピー",
|
||||
"2cf85a6a55": "ペインIDのコピー",
|
||||
|
||||
@@ -3033,6 +3033,7 @@
|
||||
},
|
||||
"TerminalContextMenu": {
|
||||
"b4cdd9314e": "화면 지우기",
|
||||
"resetTerminal": "터미널 초기화",
|
||||
"8c17d6786d": "창 닫기",
|
||||
"copyTerminalId": "터미널 ID 복사",
|
||||
"2cf85a6a55": "창 ID 복사",
|
||||
|
||||
@@ -3033,6 +3033,7 @@
|
||||
},
|
||||
"TerminalContextMenu": {
|
||||
"b4cdd9314e": "清屏",
|
||||
"resetTerminal": "重置终端",
|
||||
"8c17d6786d": "关闭窗格",
|
||||
"copyTerminalId": "复制终端 ID",
|
||||
"2cf85a6a55": "复制窗格 ID",
|
||||
|
||||
@@ -25,7 +25,10 @@ export {
|
||||
consumePendingWebRuntimeSplitMirrorTelemetry,
|
||||
closeWebRuntimeTerminal,
|
||||
updateWebRuntimePaneLayout,
|
||||
setWebRuntimeTabProps,
|
||||
clearWebRuntimeTerminalBuffer
|
||||
setWebRuntimeTabProps
|
||||
} from './web-runtime-terminal-actions'
|
||||
export {
|
||||
clearWebRuntimeTerminalBuffer,
|
||||
resetWebRuntimeTerminalInputModes
|
||||
} from './web-runtime-terminal-buffer-actions'
|
||||
export type { WebRuntimeSplitSource } from './web-runtime-split-focus'
|
||||
|
||||
@@ -279,32 +279,3 @@ export function setWebRuntimeTabProps(args: {
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks.
|
||||
export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
const remote = parseRemoteRuntimePtyId(ptyId)
|
||||
const environmentId = remote?.environmentId?.trim()
|
||||
if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) {
|
||||
return false
|
||||
}
|
||||
void window.api.runtimeEnvironments
|
||||
.call({
|
||||
selector: environmentId,
|
||||
method: 'terminal.clearBuffer',
|
||||
params: { terminal: remote.handle },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
.then((response) => {
|
||||
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ clear: unknown }>)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[web-runtime-session] failed to clear terminal buffer:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
|
||||
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { isWebRuntimeSessionActive } from './web-runtime-session-environment'
|
||||
|
||||
// Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks.
|
||||
export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean {
|
||||
return callWebRuntimeTerminalAction(ptyId, 'terminal.clearBuffer', 'clear terminal buffer')
|
||||
}
|
||||
|
||||
// Why: same as clear; the host's snapshot would re-arm the modes on the next replay.
|
||||
export function resetWebRuntimeTerminalInputModes(ptyId: string | null | undefined): boolean {
|
||||
return callWebRuntimeTerminalAction(
|
||||
ptyId,
|
||||
'terminal.resetInputModes',
|
||||
'reset terminal input modes'
|
||||
)
|
||||
}
|
||||
|
||||
function callWebRuntimeTerminalAction(
|
||||
ptyId: string | null | undefined,
|
||||
method: 'terminal.clearBuffer' | 'terminal.resetInputModes',
|
||||
action: string
|
||||
): boolean {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
const remote = parseRemoteRuntimePtyId(ptyId)
|
||||
const environmentId = remote?.environmentId?.trim()
|
||||
if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) {
|
||||
return false
|
||||
}
|
||||
void window.api.runtimeEnvironments
|
||||
.call({
|
||||
selector: environmentId,
|
||||
method,
|
||||
params: { terminal: remote.handle },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
.then((response) => {
|
||||
unwrapRuntimeRpcResult(response)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
`[web-runtime-session] failed to ${action}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
signal: () => {},
|
||||
// Web panes clear the host buffer via the terminal.clearBuffer runtime RPC.
|
||||
clearBuffer: () => {},
|
||||
// Likewise terminal.resetInputModes.
|
||||
resetInputModes: () => {},
|
||||
kill: () => Promise.resolve(),
|
||||
ackColdRestore: () => {},
|
||||
ackData: () => {},
|
||||
@@ -84,6 +86,7 @@ export function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
onSpawned: () => noopUnsubscribe,
|
||||
onSerializeBufferRequest: () => noopUnsubscribe,
|
||||
onClearBufferRequest: () => noopUnsubscribe,
|
||||
onResetInputModesRequest: () => noopUnsubscribe,
|
||||
sendSerializedBuffer: () => {},
|
||||
declarePendingPaneSerializer: () => Promise.resolve(0),
|
||||
settlePaneSerializer: () => Promise.resolve(),
|
||||
|
||||
@@ -1137,6 +1137,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'terminal.read': TerminalRead,
|
||||
'terminal.recoverPane': TerminalRecoverPane,
|
||||
'terminal.rename': TerminalRename,
|
||||
'terminal.resetInputModes': TerminalHandle,
|
||||
'terminal.resizeForClient': TerminalResizeForClient,
|
||||
'terminal.resolveActive': TerminalResolveActive,
|
||||
'terminal.resolveIdentity': TerminalHandle,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Reset Terminal grounds the input modes an app left armed where the host saw
|
||||
* no command end, and the ground survives park and reveal. The reveal builds a
|
||||
* fresh xterm from the host's model, so a renderer-only reset would come back
|
||||
* armed.
|
||||
*/
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveTabId,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import { openTerminalContextMenu } from './helpers/terminal-pane-title-actions'
|
||||
import {
|
||||
clearTerminalPtyWriteLog,
|
||||
installTerminalPtyWriteSpy,
|
||||
readTerminalPtyWrites
|
||||
} from './helpers/terminal-pty-write-spy'
|
||||
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
||||
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
|
||||
const KITTY_SHIFT_ENTER = '\x1b[13;2u'
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
|
||||
})
|
||||
|
||||
type XtermInputModes = { kittyFlags: number | null; mouse: string | null }
|
||||
|
||||
async function readXtermInputModes(page: Page): Promise<XtermInputModes> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const tabId = state?.activeTabId ?? null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm exposes kitty flags only on its private core; null when absent.
|
||||
const terminal = pane?.terminal as
|
||||
| {
|
||||
modes?: { mouseTrackingMode?: string }
|
||||
_core?: { coreService?: { kittyKeyboard?: { flags?: number } } }
|
||||
}
|
||||
| undefined
|
||||
return {
|
||||
kittyFlags: terminal?._core?.coreService?.kittyKeyboard?.flags ?? null,
|
||||
mouse: terminal?.modes?.mouseTrackingMode ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why Shift+Enter: the policy emits CSI-u only when the pane mirror reports kitty flags.
|
||||
async function readShiftEnterWrite(page: Page, app: ElectronApplication): Promise<string> {
|
||||
await clearTerminalPtyWriteLog(app)
|
||||
await focusActiveTerminalInput(page)
|
||||
await page.keyboard.press('Shift+Enter')
|
||||
let written = ''
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
written = (await readTerminalPtyWrites(app)).join('')
|
||||
return written.length
|
||||
},
|
||||
{ timeout: 5_000, message: 'Shift+Enter wrote nothing' }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
return written
|
||||
}
|
||||
|
||||
async function expectGrounded(page: Page, app: ElectronApplication, when: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() => readXtermInputModes(page), { timeout: 10_000, message: `xterm armed ${when}` })
|
||||
.toEqual({ kittyFlags: 0, mouse: 'none' })
|
||||
expect(await readShiftEnterWrite(page, app)).not.toContain(KITTY_SHIFT_ENTER)
|
||||
}
|
||||
|
||||
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((tabId) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Orca store unavailable')
|
||||
}
|
||||
state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null)
|
||||
state.setActiveTab(tabId)
|
||||
}, tabId)
|
||||
await expect.poll(() => getActiveTabId(page)).toBe(tabId)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
}
|
||||
|
||||
test('Reset Terminal grounds modes an unhooked crash left armed, through park and reveal', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
test.skip(process.platform === 'win32', 'ConPTY panes withhold the kitty protocol')
|
||||
await installTerminalPtyWriteSpy(electronApp)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const tabId = await getActiveTabId(orcaPage)
|
||||
if (!tabId) {
|
||||
throw new Error('no active terminal tab')
|
||||
}
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
||||
|
||||
// `exec` drops the shell hooks, so no OSC 133;D ever lets the host ground the
|
||||
// modes the printf arms and leaves behind.
|
||||
await sendToTerminal(orcaPage, ptyId, 'exec bash --norc --noprofile\r')
|
||||
await sendToTerminal(orcaPage, ptyId, "printf '\\033[>5u\\033[?1000h\\033[?1006h'\r")
|
||||
await expect
|
||||
.poll(() => readXtermInputModes(orcaPage), { timeout: 10_000 })
|
||||
.toEqual({ kittyFlags: 5, mouse: 'vt200' })
|
||||
expect(await readShiftEnterWrite(orcaPage, electronApp)).toContain(KITTY_SHIFT_ENTER)
|
||||
|
||||
await openTerminalContextMenu(orcaPage)
|
||||
await orcaPage.getByRole('menuitem', { name: 'Reset Terminal', exact: true }).click()
|
||||
await expectGrounded(orcaPage, electronApp, 'after Reset Terminal')
|
||||
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForActivePanePtyId(orcaPage)
|
||||
await expectGrounded(orcaPage, electronApp, 'after park and reveal')
|
||||
})
|
||||
Reference in New Issue
Block a user