Repository navigation
Conversation
…e slices (stablyai#24126) Reads the 111 files that wave-26 auditors named as NOT REACHED in their own disclosures — the tracked remainder every prior wave ended with. Three chunks of 37, sized so finishing was achievable rather than optional. 8 case declarations removed across 5 files, 222 lines gone. No file deleted whole, no production code touched. The first chunk read all 37 of its files case-by-case (15,440 lines) and found one case: a strict subset of a sibling in the same file with an identical event sequence (UserPromptSubmit, two SubagentStart, Stop, child PermissionRequest, lead PreToolUse, staggered SubagentStop) and byte-identical final assertions, differing only in a prompt string and a child-id spelling. The surviving sibling additionally asserts `mainAgentState === 'working'` after the first child stops, so it is the stronger owner. The rest are duplicate invocations and cases whose input cannot reach the behavior they name, in `src/main/claude` and the renderer editor/worktree slices. The more useful result is a KEEP that corrects a rule I gave these auditors. A case iterating the production constant `CURSOR_EVENTS` looks exactly like the validator-tested-against-its-own-list pattern, and would have been deleted under the rule as I wrote it. It is legitimate: its per-entry expectation is a test-local map closed with `satisfies Record<CursorEvent, string>`, so adding a production event leaves a missing key and removing one leaves an excess key — either fails typecheck. The drift the tautology version cannot detect is caught here by the compiler rather than the assertion, and the loop still asserts the installed shell command carries that exact literal. That gives the pattern three variants and one question that resolves all of them: if production changed, would anything fail — an assertion, or the typechecker? Syntax does not answer it; provenance and enforcement do. Also kept, each verified rather than assumed: a generated-script test asserting guard ORDERING by `indexOf`, which is the only thing catching the stablyai#11549 hang class and the stablyai#2426/stablyai#15462 deny-by-silence class, and which no behavioral test can reach; two WSL suites that partly overlap but drive different layers, since only one reaches `WslHookRelayManager.ensureForDistro` for distro ownership and generation dispose/replace; and a thin Amp case kept because a single-path ripgrep confirmed `hasExplicitPrompt` for that provider is asserted nowhere else in the repo. Reported rather than acted on: a Grok case living at the end of `server-copilot-normalization.test.ts`. Misfiled, not junk, and HARD RULE 1 forbids moving it. No case was found where both a test and its supposed owner were inert. Verified: 601 test files / 5,951 cases pass across the touched areas; `check-reliability-gates.mjs` 140 gates; all 8 deleted case titles independently grepped against the gate manifest with zero hits.
…tself (stablyai#24132) Resumes the backlog sweep at a chunk size that actually gets read. Six auditors, 84 files each, and all six read their full scope case-by-case against production — the first wave where every chunk closed with no gap. 33 case declarations removed across 22 files, 1 test file deleted, 826 lines gone. No production code touched. This wave exists because a conclusion of mine was wrong. I had recorded that yield collapsed ~36x and that deletion was no longer the high-value work. I was dividing cases removed by files IN SCOPE while the fraction auditors actually READ fell from 100% to about 4%, because I kept handing them 300-800 files. Recomputed against files read, yield has been flat at 4-7 per 100 with no downward trend. This wave came in at 8.2. The most instructive removal looked like the most valuable test in scope. `orchestration-worker-release-reap-fixed.func.test.ts` cites a production bug by two identifiers, describes orphaned PTYs accumulating until `TasksMax=4096` aborts processes on EAGAIN, and advertises itself as the functional tier wiring the real orchestration RPC surface, the real `OrchestrationDb` and the real release modules. Deleting it leaves no reference to that bug anywhere in `src`. It still had to go: its fake runtime performed the fence it asserted — if (pty.incarnationId !== inc) { return null } handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch }) — so the case checking that a reused ptyId with a mismatched incarnation does not resolve was checking a decision its own spy made twenty lines earlier. The real fence is owned by `orca-runtime-terminal-handle-incarnation.test.ts:257`, and the other two cases replay `orchestration-worker-release-incarnation-fallback.test.ts` (which uses a plain `mockReturnValue` rather than reimplementing the remint) and `worker/worker-release.test.ts:23`. "Integration test" and "wires real modules" describe the scaffolding, not the asserted step. Other removals: a self-comparison disguised by an alias, where `export const getIssueOwnerRepo = getOwnerRepo` makes a case asserting the two "agree" into `f(x) === f(x)`; four cases whose `vi.mock` of `resolveIssueSource` made both the preference value and the topology inert; five verdict-precedence cases owned by a verdict-agnostic block; three call-shape probes on one-line store pass-throughs whose real contracts are driven by behavioural neighbours; and a `export type _Ref = [...]` declaration whose own comment admits it exists only to preserve test-only module-surface references. Kept after checking production rather than shape. An auditor found two near-identical ten-reconnect loops and kept both: one uses a test-local live-lease filter, the other the shipped `sshRemotePtyLeaseAllowsReattach` predicate, and the file's own comment explains the duality is deliberate "so the two cannot drift". Another kept a paths-alignment case that looks like a validator tested against its own list, because adding a generated file without registering its path does fail it — and `shellReadyWrappersExist` uses that registered list to decide whether a partial tree needs regeneration. Production duplication is now confirmed four times over, and it is why mirrored tests exist: `createUpdateWorktreeLineage`/`createAssignWorktreeParent` differ by one `console.error` string; `terminal-path-tap.ts` and `document/path-tap.ts` carry hand-maintained copies of `matchFilePathAtColumn` under a docblock reading "keep the two in sync". In those cases both test sides are load-bearing and the duplication belongs on a refactor list. `mobile/tests-typecheck-baseline.txt` loses one entry. Trimming `relay-host-signed-out-verdict.test.ts` made it typecheck clean, so the ratchet required pruning its grandfathered entry — the file graduates from exempt to enforced. Baseline is now 124 entries, down from 125. Verified: 690 test files / 7,560 cases pass across the touched desktop areas; the modified mobile files pass (162 cases); `check-tests-typecheck-ratchet.mjs` OK (898 files in program, 124 grandfathered); `check-reliability-gates.mjs` 140 gates; the deleted file is absent from the gate manifest, `cloud/package.json` and the mobile baseline; nothing under `mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.
…not express (stablyai#24139) Backlog chunks 06-11, six auditors at 84 files each. All six read their full scope case-by-case against production — the second consecutive wave with no disclosed gap. 68 case declarations removed across 49 files, 2 test files deleted, 1,159 lines gone. The sharpest deletion is also the audit's best detection signal. `getFolderWorkspacePrimaryActionLabel(): string` takes zero parameters and returns a constant `translate(...)`. Its test, titled "uses a stable workspace creation label independent of quick agent selection", called it as (getFolderWorkspacePrimaryActionLabel as (...args: unknown[]) => string)({ id: 'codex' }) The cast is the evidence: the author had to defeat the type system to express the premise, because the dimension the title names cannot reach the function. That shape is greppable, and it is already against house style — AGENTS.md permits no type assertions but `as const`, and "production accepts an argument it does not declare" is not a defensible SAFETY rationale. Other removals, each with the owner named in the report: - `metaKey`/`ctrlKey`/`shiftKey` varied across 8 cases against an onClick handler that calls only `stopPropagation()` and `onOpenHostedReviewInChecks()` and inspects no modifier; GitHub and GitLab share one branch there. - `resolveVisibleCreatePrHeaderAction` is `return createPrHeaderAction`, an identity function, behind two titles naming "the body composer is open" — a dimension its one-field signature cannot express. Whole file. - `statPath` returning `{isDirectory:false}` means the `loadDir` branch is never entered, so "falls back when directory loading fails" is unreachable. - A "hide sleeping" case that searched a test-local literal array which itself hardcodes the key being looked for, so the knob was always defined. - `updater.startup-scheduling`: production has no platform branch — `verifyUpdateCodeSignature` appears only inside a security comment — so the darwin case asserted the same absence as the win32 case. The win32 case stays; it is what makes that comment fail CI. - Logical subsumption rather than textual: a case asserting `shallow` equality where its neighbour asserts `toBe` on identical fixtures. `toBe` implies `shallow`, so it cannot fail where the survivor passes. - Replays across bare re-exports and one-line adapters, including a 3-case describe over `return selectWorktreeAgentOrchestration(state, worktreeId)` whose owner runs 300 seeds against an independently transcribed oracle. One production line goes: a test-only `export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry'` in `SidebarNav.tsx`. Both symbols now appear only at their definition site, with zero importers. Kept after checking production rather than shape, and reported: GitHub and GitLab ready-for-review pairs that route through different APIs and negotiate different capability tokens; the `windows-process-tree-kill` / `windows-live-tree-kill.win32` pair, whose win32 header argues the duality including a disclosed refusal-orphans-descendants asymmetry; and a fake-`closest` test that looks like a previously-deleted shape but whose predicate really does read the attribute the test sets. Verified: 5,005 test files / 50,332 cases pass across the touched areas; `pnpm tc` clean after clearing `.tsbuildinfo`; `check-reliability-gates.mjs` 140 gates; both deleted files absent from the gate manifest, `cloud/package.json` and `mobile/tests-typecheck-baseline.txt`. Four failing files were checked and none is in this diff: `session-scanner-codex-workers`, `browser-manager-tab-identity` and `browser-manager-viewport-ownership` fail identically on a pristine `origin/main` worktree, and `structured-chat-coordinator-mail` is a load-sensitive `vi.waitFor` that passes in isolation and on CI re-run.
…lyai#24144) Backlog chunks 12-17: sidebar, hooks and four renderer/lib chunks, six auditors at 84 files each. 38 case declarations removed across 27 files (56 executed cases, one deletion was a 13-entry `it.each`), 1 test file deleted, 710 lines gone. No production code touched. The theme this wave is a test whose own scaffolding makes the decision it claims to check: - `useAutoAckViewedAgent.test.ts` — its `runAutoAckScan` helper reassembles the hook's scan loop, calling `resolveAutoAckTabTargets`, `createTerminalAttentionSurface`, `resolveViewedUnreadSubjectKey`, `shouldClearWorkspaceAttention` and `applyAgentAttentionAcknowledgement` in production's order. The test, not the hook, decides the outcome. Owner drives the real hook through `renderHook`/`rerender`. - `WorktreeList.lineage-agent-expansion-coupling.test.tsx` — a CONTROL case that was green both before AND after the fix it brackets. Expansion now lives in a module-level cache (`worktree-card-agents-expansion-state.ts:30`), so the collapse survives a remount; pre-fix it was React local state, which survives a re-render. The two arms it claims to contrast never reached different code. - `agent-paste-draft.test.ts` — a self-comparison over lazily-chunked draft arrays. - `remote-workspace-session-merge-local-survival.test.ts` — its own comment concedes the tab survives, and the assertion loops `expect(['agent','closed']).toContain(tab.id)` over a fixture containing only those two ids, so it cannot fail except on a fabricated id. Also removed: duplicate invocations where production reaches the asserted branch before the varied input is read (`resolve-zoom-target.ts:43-45` returns `'ui'` for browser tabs before any focus signal); a telemetry fallback already covered by the `it.each` row for the one call site that names no source; and `lazy-with-retry.right-sidebar-syntax-error.test.ts` whole, whose two cases drive one branch with `')'` vs `']'` while the owner cites the same crash report (e08749bb) and asserts the same pair. Two corrections to my own guidance came out of this wave, both from auditors measuring what I told them: 1. The widening-cast "detection signal" I introduced last wave is NOT greppable. An auditor checked all 200+ `as never`/`as unknown as` hits in its chunk and found zero function-signature widening; repo-wide, `as unknown as` matches 192 renderer/lib test files and the narrow `as (...args: unknown` form matches 26 that are nearly all the legitimate idiom for forwarding to a real implementation while partially mocking a module. Whether the cast target is the SUBJECT or a COLLABORATOR is semantic, not syntactic. Downgraded to a reading aid. 2. That is the ninth syntactic proxy I have proposed for a relationship that exists only between a test and its production counterpart, and the ninth to fail measurement. No such proxy exists; reading the case against production is the method. Two new defect classes recorded, both invisible in the test's own text: a FIXTURE that reimplements the production rule (`runtime-session-mirror-unverifiable-host.test.ts:47` builds inputs with the same `verification === 'verified' && !retired` expression production uses at `runtime-status-snapshot.ts:28`, so the test keeps passing against a stale premise if the rule changes — already diverging, since a sibling projection adds a third condition); and the stale CONTROL above. With anti-vacuity, that is three ways a test can silently stop testing what it claims while staying green. Coverage is partial and stated as such: 54 to 84 of 84 per chunk, every auditor listing its unread paths. `renderer/lib` files are larger than earlier areas, which is where the gap comes from. Verified: 1,076 test files / 9,765 cases pass across the touched areas; `check-reliability-gates.mjs` 140 gates; the deleted file is absent from the gate manifest, `cloud/package.json` and `mobile/tests-typecheck-baseline.txt`.
…24150) * test: retire two agent-status ipc cases whose fixture arms reach identical code Follow-on from the wave-30 sweep: an auditor continued into its own disclosed unread list and read the `useIpcEvents-agent-status-*` family. 2 case declarations removed across 2 files, 152 lines gone. No production code touched. - `useIpcEvents-agent-status-queue-ordering.test.ts` — `applies ready push events for an unmounted inactive terminal tab`. Character-identical to `applies ready push events for inactive terminal tabs with empty layout snapshots` in `useIpcEvents-agent-status-hook-titles.test.ts` — same event, same prompt strings, same six-argument `setAgentStatus` assertion — except for one fixture field: `terminalLayoutsByTabId: {}` versus `{'tab-future': {root: null, ...}}`. The deciding line is `agent-status-pane-routing-index.ts:209`, `if (layout?.root)`. Both `undefined?.root` and `{root: null}.root` are falsy, so both arms skip the leaf-membership branch and every downstream line is identical. The `hook-titles` copy is kept because it has a genuine local contrast arm — `buffers ready push events until a mounted tab contains the pane leaf`, with `root: {leafId: STALE_LEAF_ID}` producing `exists: false`, buffering and `agent_hook_unattributed` telemetry. The queue-ordering copy had no such pairing. - `useIpcEvents-agent-status-pane-teardown.test.ts` — `does not retain a Cursor spinner terminal title when the hook reports done`. Production IS mirrored here (`SYNTHETIC_AGENT_TITLE_PROFILES` has 9 entries), so the mirrored-production check applied: what differs between the codex and cursor rows is `synthesizeWorkingTitle: false`, and neither case asserts it. Both exercise only `done -> idleLabel` through one shared path in `resolveAgentStatusTerminalTitle`. The unique contribution is owned verbatim by `src/renderer/src/lib/agent-status-terminal-title.test.ts`, same glyph and same string, and the Codex survivor asserts a strict superset — it additionally pins `updateTabTitle('tab-future', 'Codex ready')` called exactly once. Reported, not fixed: `ipc-events-agent-status-store-test-fixtures.ts:84` hand-copies the monotonicity rule as `updatedAt < existing.updatedAt`, while production's decision lives in `agent-status-live-entry-builder.ts` and additionally weighs retirement fences, recently-closed tabs, pane-authority aliases and per-connection watermarks. Rather than reason about the risk the auditor instrumented the branch and ran all eight files: never taken, 56 of 56 cases. So it is dead test-support code today rather than a stale premise — and a trap for the first staleness case anyone writes in these files, which would pass against the simplified copy. Left unmodified because the module is shared with three specs outside the audited chunk. Verified: 172 test files / 1,084 cases pass in `src/renderer/src/hooks`; `check-reliability-gates.mjs` 140 gates; neither deleted title appears in the gate manifest; both named owners confirmed present. * test: retire two runtime cases subsumed by an honestly-named neighbour Final chunks of the 2,269-file backlog, `src/renderer/src/runtime`. 2 case declarations removed across 2 files, 30 lines gone. No production code touched. Both deletions share a shape: the surviving neighbour is not merely equivalent, it is the case whose NAME tells the truth about what the code does. - `paired-reconnect-sidebar-agent-count.test.ts` — `the erased rows are exactly the panes whose status only the client wrote`. Two defects at once. It computes `missing` by filtering the same `reconnected.rowPaneKeys` that the immediately preceding case already asserts equals the full pane set, so `expect(missing).toEqual([])` cannot fail where that case passes. And its title promises a characterisation its own assertion denies: it claims the erased rows ARE the client-only panes, while asserting the erased set is empty. Its comment describes a "causal boundary" that `toEqual([])` never establishes. Owner: `keeps a sidebar row for every still-live pane after a long sleep`, immediately above. - `runtime-terminal-inspection.test.ts` — `can record a runtime input marker from a PTY id mapping`. Its fixture sets `experimentalAgentHibernation: true`, and that flag appears ZERO times in `runtime-terminal-inspection.ts` — `recordRuntimeTerminalInputForPtyId` resolves a pane key and records, reading no setting. The surviving case sets the same flag to `false` and asserts the identical `toBe(123)` from the identical call, so the two differ only in an inert field. Owner: `records runtime input markers even before hibernation is enabled`, whose name documents the flag-independence the pair accidentally demonstrates. Verified: 209 test files / 1,703 cases pass in `src/renderer/src/runtime`; `check-reliability-gates.mjs` 140 gates; neither deleted title appears in the gate manifest. * test: retire cases whose varied field production returns from one branch Final chunk of the backlog sweep, `renderer/runtime` tab-sync. 4 case declarations removed across 2 files, 57 lines gone. No production code touched. - `web-session-tabs-sync-agent-handoff.test.ts` — `keeps stale local agent tabs when the host mirror is for a different agent`. `shouldReplaceTerminalTab` has no agent-kind branch, and `launchAgent` appears ZERO times in `terminal-surfaces.ts` — its own comment says "agent kind is not session identity". Both this and its sibling enter the same `exactProvisionalHandoffs`-empty branch, and the sibling (same-agent) is the one that would go red if agent-kind matching were reintroduced. This one cannot. - `web-session-tabs-sync-client-owned-page-content.test.ts` — three cases asserting `loading`, `canGoBack` and `canGoForward` individually. `resolveMirroredBrowserPageContent` returns all five fields from a single `if (clientHostsMirroredBrowserPage(tab) && existingPage)` branch, so no production path preserves one and drops another. Six per-field cases ran off a byte-identical fixture. Kept the `title` case (the reported bug) and the `url` case (production documents url as independently load-bearing for the next snapshot's url-equality arm), plus two cases the per-field reads cannot cover: one asserting different state slices, one asserting patch-key omission. Reported, not fixed: `web-runtime-session-tab-activate-close.test.ts` asserts a stale-terminal refusal is indistinguishable from a real close, while its own comment says "Any fix must make these two outcomes distinguishable". As a plain `it` it will go red when the bug is fixed and read as a regression; `it.fails` is the honest shape. Second instance of that pattern in this audit. Verified: both files green (24 cases); `check-reliability-gates.mjs` 140 gates; neither deleted title in the gate manifest.
…lity (stablyai#23982) * fix(orchestration): authorize worker reports without the dispatch capability Worker lifecycle reports and questions no longer depend on the per-dispatch capability token that lives only in the agent's conversation. The host now: - ignores capability_hash/capability_revoked_at for authorization on every row and checks the exact worker process instead (ask gains that check); - refuses a report whose calling terminal is provably another orchestration party (a Run coordinator or another Dispatch's worker), treating env that names no live pane here as absent; - applies one worker-state rule locally and remotely: a stop in flight refuses, while stop_unknown and start_unknown accept and settle. Minting and printing the flag are unchanged, so an older host and older preambles keep working. * fix(orchestration): name the fenced party without implying which Dispatch it owns * refactor(orchestration): one worker report rule, fence only a different party - One module owns the unproven/settleable worker states and the refusal rule; local send records it, ask and remote throw it. A stale process is worker_identity_changed on every path. - The caller fence passes the worker's own terminal when its --from handle went stale. - Document the shared-tmux-server limit; drop the dead dispatch_capability_invalid rejection member; tests assert dispatch state, not the capability column. * test(orchestration): drop capability-era assertions other tests already cover * test(orchestration): cover a current process whose terminal moved to another pane
…ly unreachable (stablyai#23984) * fix(orchestration): retry worker_done while the Orca runtime is briefly unreachable A worker reports worker_done once and ends its turn, so a few-minute app outage silently stranded finished work at dispatched. The CLI now retries worker_done on runtime_unavailable for about two minutes with backoff, reusing one request id so the host's mutation ledger replays rather than double-applies it, then prints the existing recovery command. The contract probe no longer caches a failed status.get, which otherwise made every retry fail without reaching the app. Part of STA-8833. * refactor(cli): pass the worker_done retry window in the mutation options bag * Revert "refactor(cli): pass the worker_done retry window in the mutation options bag" The options bag is forwarded to client.call as-is; the retry window is not a client.call option, and folding it in needed a value scan to keep the no-options call shape. * test(cli): fold the explicit retry-request case and drop a vacuous timing assert * fix(cli): keep worker_done recovery when the last retry fails before sending Also skip the Unix-socket retry test on Windows and remove its temp profile.
…stablyai#23994) * fix(orchestration): authorize worker reports without the dispatch capability Worker lifecycle reports and questions no longer depend on the per-dispatch capability token that lives only in the agent's conversation. The host now: - ignores capability_hash/capability_revoked_at for authorization on every row and checks the exact worker process instead (ask gains that check); - refuses a report whose calling terminal is provably another orchestration party (a Run coordinator or another Dispatch's worker), treating env that names no live pane here as absent; - applies one worker-state rule locally and remotely: a stop in flight refuses, while stop_unknown and start_unknown accept and settle. Minting and printing the flag are unchanged, so an older host and older preambles keep working. * fix(orchestration): stop minting the dispatch capability Dispatches no longer mint a per-Dispatch token, and preambles, the bundled skill guide and the ask resume hint stop printing --dispatch-capability. The consumer-generation bump and delivery fence that minting carried stay, now as setDispatchConsumer. Readers that inferred meaning from capability_hash read what they meant instead: worker-show's injected stage comes from the attached consumer, and a failed start copies custody identity only when no authority was ever attached. The CLI keeps accepting and forwarding the flag for older hosts. Cancelling a Task is recorded as failed with a reason; task-list now shows that reason and the guide and task-update notes document the recipe. * fix(orchestration): name the fenced party without implying which Dispatch it owns * refactor(orchestration): one worker report rule, fence only a different party - One module owns the unproven/settleable worker states and the refusal rule; local send records it, ask and remote throw it. A stale process is worker_identity_changed on every path. - The caller fence passes the worker's own terminal when its --from handle went stale. - Document the shared-tmux-server limit; drop the dead dispatch_capability_invalid rejection member; tests assert dispatch state, not the capability column. * refactor(orchestration): drop setDispatchConsumer and the dead capability retention - dispatch --inject no longer re-points the row createDispatchContext just wrote; worker-show reports every worker-less Dispatch as context_only, since Orca keeps no record of the paste. Tests re-point through a fixture. - failWorkerStart always records when the lifecycle closed; nothing authorizes on it. - Restore the ask resume hint's echo of a passed --dispatch-capability: an old host checks it before --resume. - Move the cancellation convention to stablyai#23983. * test(orchestration): drop capability-era assertions other tests already cover * refactor(orchestration): drop the host-side capability field and no-op test fixtures - RpcRequest and the SSH bridge stop carrying orchestrationCapability; the CLI's wire field stays for older hosts. - Fixtures pass identity to createRootDispatch instead of re-pointing to the same values; drop absence checks for a flag that can no longer be produced. * test(orchestration): cover a current process whose terminal moved to another pane * chore(orchestration): finish the capability cleanup in test stubs and skill wording * test(orchestration): drop needless response casts; mark the db stub cast safe
… who did it (stablyai#23983) * fix(orchestration): worker-abandon settles a stuck worker and records who abandoned it worker-abandon refused or no-oped in the states it exists to escape: a stop stranded by a dead runtime, an active attempt that was no longer the Task's latest, and settled workers whose terminal release was stuck at requested or unknown. It now settles every non-terminal worker except this runtime's own in-flight stop, records who abandoned it, and retains (never closes) an owned terminal whose release is not already in flight. Part of STA-8833. * refactor(orchestration): abandon retains through worker-retain's rule; record cancellations - One retain helper serves worker-retain and worker-abandon. A committed release (releasing, unknown) keeps its state and archive, since the tab may already be closed; a retained terminal drops its stale archive. - worker-abandon keeps the published stale field (this attempt was not the Task's current one) on the worker path. - task-list shows a failed Task's reason, whitespace-collapsed; task-update help and the recovery guide document cancel = failed + --result cancelled. * test(orchestration): drop duplicate abandon asserts; leave task-list output unchanged Cancellation stays documented in task-update help and the recovery guide. * fix(orchestration): abandoning an already-settled worker changes nothing Only the settling path retains an owned terminal. * fix(orchestration): attribute abandon only to a verified caller and keep the prior diagnostic * fix(orchestration): report an already-settled abandon as stale, as main did
…tablyai#22619) * feat(native-chat): register Codex default-mode helpers as subagents Codex's default multi-agent mode announces a helper only as the collabAgentToolCall that spawned it; it sends no subAgentActivity. The roster and the background-task tracker registered children only from subAgentActivity, so such a helper had no record, no strip row and no roster row, and its commands read as the session's own. One announcement reader now yields a child from either wire shape, and both the journal roster and the tracker register through it into the same executions, so a session sending both keeps one child per thread. A finished closeAgent ends the helper's running turn as stopped through the executions, beside the child's own turn and thread frames. Each collab call renders as a tool row (spawn_agent, wait_agent, close_agent, ...) naming the helper the way the roster does, with what the helper said back as its output, instead of the raw provider row. * fix(native-chat): name a spawn row by its prompt until the roster holds its helper Also pin that the roster row appears when the helper's first turn arrives before the spawn call finishes. * test(native-chat): a spawn call keeps its own row beside the roster row it starts * fix(native-chat): pair a structured tool row's output with its own call A run paired results to calls by position alone, so once one call finished with no output (a Codex spawn_agent row) every later output drew under the call before its own. A structured row carries its call and output together, so the projected result now names its call id and pairing honors it, falling back to position for results that name none. * fix(native-chat): the Codex roster row follows the executions, so every child ending settles it The subagent-group row was rewritten only on a child's turn/started and turn/completed. A child turn ended any other way — a fatal error, its thread closing, its caller closing it — settled the strip and the host record through the executions but left the transcript row reading working until the session ended. The executions now say when a child's execution changes, and the roster revises its row from that, whichever frame changed it. handleTurn still re-derives to hand back its write admission; the revision is idempotent. * fix(native-chat): a restored Codex call row names its helper, not its thread id History replay never runs the live item router, so the roster never learned the helpers a restored thread had spawned, and a restored wait_agent, close_agent or send_input row labelled its helper with the raw thread id. Replay now registers each announced helper for its name and membership only: register starts no execution, so no strip entry, record or roster row claims the helper runs until a live turn of its own says so. The replayed-item handling moves into the restore module beside the replay that calls it. Also name the roster's render contract in handleItem, and say why the collab tool-name map is not a spelling fix. * fix(native-chat): register a Codex helper whose spawn call failed but created its thread Codex reports a spawn as failed when the helper it created errored at birth, yet the call still names the thread it created, and that thread can run. The spawn was registered only when the call completed, so such a helper existed nowhere and its shell read as the session's own bare command: the original default-mode bug. A spawn now registers whenever it names a receiver; one in progress, or one that created nothing, names none. A failed closeAgent still ends nothing, because the helper was not closed. * refactor(native-chat): each Codex thread's latest token total gets its own home Every thread's running total, member or not, with the rule that the newest frame replaces the last and the recency-ordered cap, moves out of the roster into codex-thread-token-totals.ts. The roster still selects its children's totals at write time. With the producer linkage the roster now builds, it was past the size limit. * refactor(native-chat): the messages a session list quotes get their own module The readers of a session's newest own prompt and own assistant prose move from the status projection into structured-agent-session-latest-messages.ts, and are re-exported so their consumers keep one import site. With the status clock the projection now carries, a tool result naming its call put it past the size limit. * test(native-chat): a Codex helper's command is a live record until its process reports its exit A helper's in-turn shell is a live command record owned by the helper and is also its open Bash operation; its exit removes the record. A caller's closeAgent kills the helper's processes, and each still reports its exit on the helper's thread, so the close leaves the command to that exit rather than ending it itself. * fix(native-chat): every reader of a tool run pairs a result with the call it names The folded desktop run now pairs a result with the call it names, but mobile's tool run, the desktop edit cards and the task lists still paired by position through `pairToolBlocks`. In a Codex default-mode session the new `spawn_agent` row finishes with no output, so on mobile the helper's reply drew under `spawn_agent` and `wait_agent` showed none, and a desktop edit card could take the next command's output as its own. `pairToolBlocks` now follows the same rule as `pairNativeChatToolResults`: a result that names its call answers that call, and one that names none answers the oldest unanswered call as before. * fix(native-chat): a Codex helper's turn ends on its own frames, not on its caller's closeAgent A finished `closeAgent` call ended the helper's running turn as `stopped`. But the call's status is not the close's outcome: Codex sets it from the helper's own agent status, so a close that errors on a running helper still reports `completed`. Orca then marked a helper that was still running as cancelled in the strip, the host record and the roster row, and because the first ending a turn gets stands, its real ending could never correct it. A close that works does not need the edge either. Codex's shutdown of the helper aborts its running turn, and the app-server reports that as the helper's own `turn/completed` with status `interrupted`, which Orca already maps to `stopped`. So a helper's turn now ends only on its own frames (its `turn/completed`, a fatal `error`, `thread/closed`) or the session ending, and the close is only its call row. This also drops the child-work evidence re-keying that existed only for the close: every remaining frame names the thread that sent it. * fix(native-chat): a tool result that names a call is never given to a different call A result that named a call id with no unanswered match fell back to the oldest unanswered call. On mobile, a run shows at most six calls, so a command past that window whose output named its own call gave that output to an earlier call that finished with none, such as `spawn_agent`. A result that names its call now answers only that call and otherwise stays unpaired. A result that names no call still answers the oldest unanswered one. Both pairing readers share the one rule through `answeredToolCallIndex`. * test(native-chat): name the Codex default-mode test file after the subagents it covers * fix(native-chat): a Codex helper is known from any call that names it, not only its spawn A helper whose spawn Orca never saw (history replay after compaction dropped the spawn, or a resume or message to a helper from earlier) stayed unregistered, so its shell read as the session's own command: the bug this PR fixes, in another shape. Every collab call now announces each helper it names, skipping a receiver the finished call reports notFound. The spawn's prompt still labels a helper when it was seen; otherwise the helper has no label and reads as any unnamed subagent does. A send_input, like the spawn, names the parent turn the helper's next run belongs to. The session's own thread is excluded once, in the reader, instead of at each of its three callers. * fix(native-chat): every finished Codex collab call row carries what the call reports A client that predates result call ids (an older mobile app, or an older desktop reading a newer host) projects the journal itself and pairs each tool result with the oldest unanswered call. This PR publishes collab calls as tool rows, and a finished spawn_agent, send_input, close_agent or resume_agent on a running helper had no output, so such a client drew each later output in the run under the call before its own (the parent's shell output under spawn_agent, the helper's reply under the shell). Every finished call now has an output taken from the item: a wait's reply from each helper that finished (an errored helper's error), and for every other call the helper's reported status in Codex's own words (Pending init, Running, Completed, ...). A close or resume no longer shows the helper's last reply as if the call returned it. A wait whose end names no helper (it timed out, or v2) reads Finished waiting; any other call with no state reads its own status. A call also keeps naming the helpers its started item named: Codex ends a timed-out wait with no receivers, so its row lost the helper's name when it finished. * refactor(native-chat): the desktop run's tool pairing is a view of pairToolBlocks Desktop runs and every other reader (mobile, edit cards, task lists, the ask row) each had their own pairing loop sharing one index rule. The desktop's pairNativeChatToolResults now reads the pairs pairToolBlocks makes, so a run is paired by one loop and a new reader cannot add a third. No behaviour change. * test(native-chat): type the positional-client pairs without assertions * fix(native-chat): a finished Codex collab call row says what the call did, in Codex's own words A close_agent row read `Running` and a spawn_agent row `Pending init`: each showed the helper's status snapshot from before the call took effect, so a close that stopped its helper read as though it had not worked. Each finished call's output now follows Codex's own client: spawn reads `Spawned` (or `Agent spawn failed` when no helper was created), send_input `Sent input`, close `Closed`, and resume the helper's status summary. A wait keeps each finished helper's reply, with other statuses in Codex's summary wording (`Error - <message>`). The row label already names the helper, so the output does not repeat it. * docs(native-chat): the collab call reader says which calls show the helper's snapshot as output Since spawn, send_input and close rows say what the call did, the reader's header was wrong to claim every call row shows the reported snapshot as its output: only a wait or resume does. * fix(native-chat): a Codex spawn call no longer claims it ran an agent Codex's spawn_agent call ends as soon as the helper starts, so counting it as running an agent drew "Ran 1 agent" directly above "Kicked off 1 subagent · working" while the helper was still working, and "Ran 1 agent · 1 failed" when Stop cancelled a spawn before any helper existed. Claude's Task call lasts as long as its subagent, so it keeps the agent category; the Codex spawn row is now a plain tool call and the roster row alone stands for the helper. * test(native-chat): a Codex helper's row settles on the failed completion that follows a fatal error Codex follows every turn-ending `error` with the turn's failed `turn/completed`, on a helper's thread as on the primary, and only that completion ends the turn. The roster test now sends both and checks the row, strip and record stay working through the error and settle on the completion. * fix(native-chat): a Codex helper's section opens while the parent spawns, waits on or messages it A running chat holds a subagent's section open while the parent's newest row delegates to that subagent. For Codex that rule only knew the raw collab status row; this PR writes each collab call as a tool row (spawn_agent, wait_agent, send_input, close_agent, ...) naming its helpers in `input.agents`, so a default-mode helper's section stayed shut for its whole run while Claude's opened. The delegation reader now reads a Codex collab tool row as a delegation to the first helper it names, the same rule the raw row keeps for journals written before this change; a call naming no helper stays ordinary output. The row names and the `agents` key live in one shared module the host writes from and the reader reads, instead of a second list. The new test drives the real adapter's rows through the transcript projection to the delegation; the collab frame harness moves to a fixture shared with the positional-clients test. * fix(native-chat): a Codex collab call with a long prompt still names its helper A collab call row bounded its whole input as one value, so a spawn or send_input whose prompt passed the 16 KB journal limit was stored as a clipped wrapper: the row lost the helper's name and thread ids, and with them the label and the delegation that opens the helper's section. The prompt is now clipped on its own with the journal's inline-text bound, marker included; the helper's name, ids, model and effort are bounded as before and always survive.
… app into "Session restore failed" (stablyai#23964) * fix(native-chat): report a failed startup chat reconcile instead of failing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none). * fix(native-chat): read restored chats without waiting on lease bookkeeping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads. * fix(native-chat): keep every record-store write off the startup chat read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes. * fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed. * docs(native-chat): correct restore comments and a test name to match the final design * fix(native-chat): keep a throwing failure sink from failing the startup chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn.
* fix(editor): highlight shell startup dotfiles Opening ~/.zshrc, ~/.bashrc or ~/.profile rendered as plaintext because extname() treats a leading-dot name as having no extension, and Monaco's shell association only lists .sh/.bash. Map the common bash/zsh/POSIX startup filenames to the shell language in FILENAME_TO_LANGUAGE so both the editor and diffs highlight them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(editor): cover every mapped shell dotfile Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(editor): match exact filenames case-insensitively Shell dotfiles (.ZSHRC, .BASHRC) on case-insensitive filesystems still fell to plaintext. Exact match wins; lowercase fallback covers the rest without touching extension/Monaco order. --------- Co-authored-by: djlee <djlee@woowahan.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
… messages (stablyai#24005) * fix(source-control): drop reasoning model think blocks from generated messages Custom commit-message commands that run a reasoning model print the reasoning before the answer, either as a <think>...</think> block or, when the chat template prefills <think>, as text ending in a lone </think>. The cleaner kept all of it, so the first reasoning line became the commit subject. Strip everything through the first </think> when the output starts with <think> or has no <think> before the close tag. Output that quotes both tags is left unchanged. Fixes stablyai#24004 Signed-off-by: FenjuFu <fufenjupku@gmail.com> * fix(source-control): scope lone </think> stripping to custom commands and add Kimi-VL tags A lone closing tag is only stripped for custom commands, so a built-in agent's message that mentions </think> is kept. PR fields parse the raw JSON first and strip reasoning only when that fails, so a body quoting the tag still parses. Adds Kimi-VL-Thinking's ◁think▷ tags. --------- Signed-off-by: FenjuFu <fufenjupku@gmail.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
…ease (stablyai#24037) An APK that fails to install with a missing certificate or a package-parse error is usually a download that died near the end: the signature block sits in the last ~100 KB of a 133 MB file, so a truncated APK looks complete and carries no signature at all. The release published neither a size nor a digest, so there was no way to tell that apart from a bad build without deriving both from the asset by hand. The release now uploads app-release.apk.sha256 next to the APK in `sha256sum -c` format (binary marker, so Git Bash cannot translate line endings while hashing) and puts the exact byte size and digest in the release body, naming `shasum -a 256 -c` for readers on macOS. The upload path rewrites the body too: --clobber replaces the APK, so a digest left over from the previous build would describe a file nobody can download, and a reader comparing against it would reject a good APK. Both paths reserve the section's own length out of the release-body cap before truncating, so the section always survives and the body always fits; MAX_RELEASE_BODY_LENGTH is exported from the desktop release script rather than restated. Refs stablyai#24011, stablyai#12248, stablyai#11444.
…ble (stablyai#23973) * fix(claude): keep a WSL guest's ~/.claude.json mode when Orca writes folder trust * test(claude): assert a skipped trust write leaves no replacement file behind The replacement file is now created before Claude's lock is taken, so the locked path must remove it.
stablyai#24216) * test(relay): reproduce drain-release contention against director placement Adds a Postgres harness that drives releases from an isolated cell over a 171 ms per-statement pool while five directors re-place reconnecting hosts through the sticky lane. At 18 releases/s placements fall from 20/s to about 5/s and every active director backend is blocked on relay_cells. Moves the per-statement delay pool into a shared test fixture so the rehome target-row test and this harness use one implementation. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010 * fix(relay): re-place hosts off a draining cell without locking its row Sticky re-placement of a host whose cell is isolated for a roll locked every relay_cells row. During an Asia drain the source row is held by the cell's own releases for a round trip each, so the placement waited on it, the single sticky slot backed up, and /v1/assign returned 503 fleet-wide. The isolation decision now comes from an unlocked read, and the placement locks only the same-region general rows it can move to. It no longer writes the source row: the host's source leases stay, and each one's own release or expiry takes its units back off the source. The assignment keeps its activity counters and adds one control instead of resetting them. With no same-region headroom the path falls back to the all-rows lock, as before. Dormant hosts hold no units, so their placement also locks only the general rows and skips the zero write to their old cell. The drain harness now asserts the after picture: 20 placements/s at Asia latency with no director lock waits, against 9.2/s and 4.8/s before. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010 * fix(relay): take a moved host's units off the cell that holds them After a narrowed re-placement a host keeps leases on its old cell while its assignment row names the new one. Two paths charged the row's whole counted total to the row's cell: aggregate expiry, and the lease deletion in dead-cell and stranded re-placement. Both over-charged the new cell and left the old cell's units stranded. Aggregate expiry now skips hosts that still hold any lease; the lease sweep takes each lease's units off its own cell. Placement frees each deleted lease's units on that lease's cell, charges the old cell only for units no lease backs, and sets the counters from the leases it keeps plus the new control. The narrowed path runs only when the counters already match the leases, so it never needs to write the old cell's row. The all-rows re-placement off an isolated cell follows the same rule, so it no longer decrements the source at placement either. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010 * fix(relay): try other regions before the all-rows lock when re-placing off a roll With every same-region neighbour at its connection cap, the narrowed path found no target and fell back to the all-rows lock behind the busy source row, which is the drain brownout again. It now tries a second tier, general cells in every other region, in its own transaction over one ordered lockCellRows, still never the source row. Only when no general cell in any region has room does it fall back to the all-rows path, which keeps the pin. This changes the policy from stablyai#21911, which refused to re-place an isolated host across a region. The unit tests that encoded that rule now assert the tier order instead. The drain harness gains a US cell and an arm with every Asia neighbour capped: 200 of 200 dials placed cross-region at 20/s with no lock waits, against 0 placed and 144 sticky rejections on the previous head. Its pass bars are now the rejection share and the lock-waiting share, not the placement rate a slow runner's pacing can move. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010 * fix(relay): take the narrowed path for hosts whose counters sit below their leases Main's old placement reset a moved host's counters while keeping its source leases, and those leases' releases floored the counters at zero. Such hosts hold fewer counted units than lease units, and requiring equality sent them down the all-rows path behind the busy source row. The narrowed path now requires only that the host holds no units no lease backs, the one case that needs a write to the old row. Its placement already rebuilds the counters from the kept leases plus the new control, so a drifted host is healed by its next re-placement. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010 * test(relay): judge the local drain arm on completion and lock waits, not rate The local-latency arm asserted at least 18 placements/s at a 20/s dial rate, which a slow runner's pacing alone can miss. It now asserts what the Asia arm does: no dial failures, sticky rejections under 10% of dials, every other dial placed, and director lock-waiting under half a backend. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
…ep Claude running while it holds one (stablyai#23898) * fix(claude): settle a queued send the CLI withdrew from its own cancelled frame Claude reports each uuid-stamped command's lifecycle (queued, started, completed, cancelled). A send it withdraws from its queue gets `cancelled` before the interrupt or cancel_async_message answer, so a lost or failed answer no longer leaves that send pending: it settles as withdrawn, with the same reason and words as the receipt path. A command the CLI already started also ends `cancelled` when its turn is interrupted or fails, so `cancelled` after `started` is not a withdrawal; an echoed send has left the waiter lists and is never reached. Tests replay real 2.1.280 captures, scrubbed. * fix(claude): release a doubted send when the CLI reports its session idle A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as work still owed, so the chat showed Working until the child exited. Claude sends `session_state_changed idle` only once its whole queue has drained, so it can no longer be holding that send. The runtime now routes that report to the host's existing release, the same one Codex's thread-stopped report uses; it retires `unknown` only, never `pending`. * fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1 * fix(claude): settle every terminal lifecycle state of a send the CLI never echoed A send the CLI started, then cancelled before any echo, stayed pending: it may already be in the conversation, so it is released as doubt (unknown, recovered), never withdrawn and never re-sent. A late echo still accepts it. The 2.1.280 schema has two more terminal states. `discarded` (the CLI ended its session with the send still queued) settles as not delivered; `refused` (declined before it queued) settles as not accepted by the provider. After `started`, either one is doubt, as `cancelled` is. The late-settlement path gains an `unknown` outcome, which the host records as released doubt. * fix(claude): release a send the CLI took but left unanswered when it goes idle `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that is still unanswered there got no echo and never will: a turn that throws can leave `started` with no terminal state. Idle releases it as doubt. What proves the CLI took a send is its lifecycle frame. On a CLI that reports no lifecycle, it is the send's place on stdin: one whose write finished before an interrupt went out was read before the interrupt was, so the first idle after that interrupt releases it too. A send armed ahead of the interrupt but written after it is left alone, since the CLI may still run it. * fix(native-chat): keep the idle sweep off a Claude child that holds a send A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row exists yet and the sweep rested the child after the idle window, turning the send into doubt. The adapter now reports whether the CLI holds a send (lifecycle `queued` or `started`, not yet echoed or ended), derived from the live waiters, and owed work counts it. Nothing is stored: every held send leaves the live set on its echo, its terminal lifecycle state, the CLI's idle, or the child's exit, so the hold ends with the send. * fix(claude): count only a started send at idle and as a held send 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue, so a send read in that window goes queued, idle, started. Releasing every taken send at idle doubted that live send and dropped Working. Only a `started` send is released at idle or keeps the child from the idle sweep; a `queued` one ends by starting and echoing, by a terminal lifecycle frame, or with the child. The stdin-order path for CLIs without lifecycle frames is removed: a doubted send retired there disables content matching on CLIs that mint their own echo ids, and no Orca failure called for it. Those CLIs keep the earlier behaviour. Comments that said only a failed write or child exit ends a waiter, or that idle comes only once the queue has drained, now say what ends one. * docs(claude): say only what the CLI's lifecycle frames and idle actually prove * fix(claude): hold the idle sweep while Claude has a send queued, not only started The sweep rested a child whose CLI had queued a follow-up behind a turn, dropping the send it had already taken. The hold now spans the CLI reporting it took the send until its echo, a terminal lifecycle state, or the child's exit. The idle release still covers only started sends: 2.1.280 can report idle before it re-reads its queue. * refactor(native-chat): give provider-proven late dispatch settlement its own module * test(claude): pin a steer a Stop interrupts after it started as doubt, not withdrawn
…ad-status row (stablyai#23704) * fix(native-chat): a failed Codex turn shows its error, not a raw thread-status row Codex reports `thread/status/changed` {systemError} just before the `error` frame of a failed turn. The translator read it for session state, then let it fall through to the generic-frame fallback, whose payload check reads `systemError` as a failure and printed "codex · notification:thread/status/changed" in red above the real error row. The translator now owns the notification: it reads the stopped-running verdict exactly as before, then journals nothing for any arm (idle, active, notLoaded, systemError). The error row that follows still carries Codex's sentence and still fails the turn. * refactor(native-chat): the Codex thread status is owned through the typed-translator registry The translator already reads every thread status for session state. Listing the kind beside Claude's background-task frames makes that ownership visible where the fallback consults it, instead of a second method check in the translator. * chore(native-chat): the typed-translator registry key is checked against the provider table A mistyped provider key compiled and silently brought the row back. Also retire the renamed constant from a Claude test comment and say which path the classifier-only fallback test covers. * fix(native-chat): the Codex translator vouches only for the thread status it read The covered flag means this exact frame was handled; passing it for every notification would silently hide any Codex kind later added to the registry. Also say why a state-only kind may be listed, and retitle the cross-provider coverage test now that Codex has entries.
…tablyai#23674) * fix(native-chat): a read whose history will not open is refused with its reason History, subscribe, snapshot and options reads reach a chat through one accessor, whose open had no catch: a journal that would not open reached every client as a runtime error carrying the storage's own text (a path, "file is not a database"). The accessor, and the options read's own open, now throw the classified journal refusal: journalCorrupt when SQLite reports damage, journalUnavailable otherwise. The storage text goes to the log only. The wire code stays runtime_error and the message becomes the bare code, as for every thrown refusal; the reason rides in the error's data. * refactor(native-chat): the idle sweep's stop of a hung start carries no hand-written reason The sweep passed an English sentence as the stop's reason. It lived only in memory and nothing read it: the delivery loop words the error row and the rejection from the hostStopped fact. Dropped, with the display-name lookup that built it. * fix(native-chat): a refusal the host throws is worded from its data, never its message A thrown agent-session refusal reaches the client as runtime_error with the bare code as its message and the typed refusal in error.data. Stop, answers, options and goals, a launch's held option pick, the option picker's failure toast, and the Retry line of a chat that could not start now word it from that refusal through the shared notice table. What each caller decides about the outcome is unchanged: only the words move. The Retry line of a failed start no longer prints the host's message or a thrown error's text; it keeps the refusal as a fact and says the cause and step its reason names, or only that the chat could not be started. The option toast keeps a local option surface's own sentence. * fix(native-chat): an unreadable history is worded from its refusal, and damage stops the retry The structured chat's read failure showed the host's text on the status line, and the pane always said Orca keeps trying. The read transport now takes the refusal from the error's data (a stream payload or a thrown RPC error), the reducer keeps it beside the failure text, and the pane and the status line word it through the notice table, once: on the pane when the failure took it, else beside the transcript that stays. A damaged journal says "Unable to load this chat." and the read stops reconnecting for that run; reopening the chat reads again. An open that can clear names its cause without "Try again", since the pane retries on its own. A failure that names no reason keeps today's generic line. Finality comes from the refusal's reason, never its message, which is the bare code for both. * fix(native-chat): a rejected message is worded from its stored fact A message the host recorded and then rejected keeps the host's typed fact beside its reason, but the Retry words re-read the reason alone. Now the fact decides: a hand-over failure says Orca couldn't reach the agent, a kind whose reason may be a legacy marker gets its fact's own sentence (a full queue now says so instead of only "not sent"), and any other kind shows the sentence the host wrote for it, which carries the agent's name and any words the provider wrote for a person. A row with no fact reads as before. * fix(native-chat): each message that did not go through says why on its own row The structured chat showed one Retry strip under the transcript for whichever single entry it picked, so a second failed message had no reason and no Retry of its own. The terminal-backed chat's existing per-row delivery marker now carries a notice and an optional Retry, and the structured pane derives one per message from the outbox on each render: every rejected message, and the one the queue stopped on (read through the drain's own rule, so a Retry never names a message waiting behind it). Each is worded from that message's stored failure. The single strip is deleted. Nothing new is stored, and the shared message projection is untouched. * fix(native-chat): say each chat failure's words where its own control already acts Three wording rules for the desktop chat: - A chat that could not start shows Retry beside its reason, so the reason stops at its cause where the Retry is the step: a reason whose action is to retry, and a start failure's "send your message again". Any other step stays (quit the terminal agent, start a new chat). The start-failure sentences take a retryControl context for this; what the host writes is unchanged. - A history that couldn't open right now still reconnects, so the pane keeps "Orca keeps trying to load it" under its cause. Only a damaged history, which no retry reads past, drops it. - A read failure that names no reason while the transcript is shown is only the pane reconnecting: the status line says "Reconnecting to this chat…" in muted text, not an error. New key components.native-chat.state.reconnecting, hand-translated for es/fr/ja/ko/zh. * fix(native-chat): a rejected message offers Retry only once the queue is moving Each rejected message's row offered its own Retry even while the queue was stopped on another message. Any Retry clears the stopped queue, so pressing a rejected message's Retry also sent the message the queue was holding, which the user had not retried; behind a message whose delivery is unconfirmed, the retried one instead went back into the queue with no notice and waited there. While the queue is stopped, only the message it stopped on offers Retry, as the single Retry strip this replaced did. A rejected message keeps its words on its row and gets its Retry back once the queue moves. * fix(native-chat): a chat whose history will not open logs once, not on every reconnect A reader reconnects every 750 ms while a journal open can clear, and each attempt logged the failure with its full stack. The read door now logs a session's failure once until that session opens, closes, or fails differently; every attempt is still refused with its reason. * fix(native-chat): a message's own Retry is its resend step, so its notice stops at the cause A rejected message offering Retry read "Claude stopped before it finished starting. Send your message to try again." beside that button. Its row now takes the rule the launch strip already follows: beside its own Retry the words leave out sending or trying again, worded from the stored fact with the chat's agent name. The stored fact keeps less than the host wrote from (a refusal, the provider's words), so a reason it cannot rebuild exactly is kept as written. A rejected message without a Retry, while the queue is held, keeps the step. What the host writes and the phone's notice are unchanged. * fix(native-chat): a message's Retry sends only that message, never the one the queue is held on Retry released the queue's refusal hold whichever message it was pressed on. While a queued message waited ahead of a held one, every rejected message offered Retry, and pressing it also sent the held message the person had not retried. Retrying an unconfirmed message ahead of a held one did the same. Retry now releases the hold only for its own message. * fix(native-chat): a not-signed-in failure beside Retry still says to sign in first Beside a Retry the notice dropped the whole next step, so a chat that could not start because the agent was not signed in read only the cause. Pressing Retry without signing in fails the same way again. The words now keep the sign-in step and leave out only the resend, which the Retry button is. * test(native-chat): a rejected message's hidden Retry only avoids waiting unseen * fix(native-chat): every Retry beside a notice leaves out the retry step the same way A message the queue stopped on worded its refusal with no agent name and with its retry step, beside its own Retry, while a rejected message next to it named the agent and left the step to the button. The launch strip and the history pane each had their own copy of the same rule. One wording context now goes through the one notice table for every surface: a Retry beside the words, or a pane that reconnects on its own, is the step for a reason whose action is to retry, and every other step stays. What the phone and the host write is unchanged. * test(native-chat): read the sent message id without a type assertion * fix(native-chat): a rejected message is worded from the journal's own fact, never by comparing sentences A message the host recorded and then rejected kept only the rejection's kind on the message, so its notice was reworded from that smaller copy only when it rebuilt the host's sentence word for word. A different agent name, an older host's wording, or anything the copy dropped (why a start failed, the provider's own words) left the host's sentence in place, beside a Retry that repeated its resend step. The notice now reads the journal's own rejection for that message, found by id, with the pane's agent name and Retry, and shows the provider's words only when they were written for a person. The message's smaller copy words it only when that journal row is not loaded. Nothing new is stored. * fix(native-chat): a message rejected before a restart retries under a new id the first time Whether a Retry needed a new message id was remembered in memory for one message, or read from the journal row when it was loaded. After a restart, or for an older message whose row was not loaded, the first Retry resent under the old id, the host answered with the same settled rejection, and nothing visibly happened. The message now says so itself: one the host recorded and rejected always retries under a new id, including after a restart. A refusal that already gave the message a fresh id, and a message whose delivery is unconfirmed or in flight, keep their id as before. * fix(native-chat): a rejected message older than the loaded history keeps the provider's words When the journal row that rejected a message is not loaded, the message's own copy of the fact has no provider detail or start refusal. For the kinds worded from those, the row now shows the sentence the host wrote for the person instead of a thinner rebuilt one. * test(native-chat): the chat pane words a rejected message from its loaded journal row Nothing covered the pane handing the journal's rows to the per-message notices, so a pane that stopped passing them would quietly fall back to the message's smaller copy of the rejection and show the host's sentence, resend step and all. The new case renders the pane with a rejected message whose journal row is loaded and checks that it reads that row's refusal in the chat's own agent name. * fix(native-chat): a chat whose history won't load says why in one line A read the host refused for a named reason put its sentence under the generic "Could not load conversation" title, so a damaged history read as two lines saying the same thing. The pane's own sentence now takes the title's place; a history that can come back keeps its line saying Orca keeps trying. A failure that names nothing keeps the generic title. * fix(native-chat): a message a failed start rejected says only that it was not sent When an agent stopped before it finished starting, the chat showed the start's red row ("Claude stopped before it finished starting. Send your message to try again.") and then repeated that cause under every message the start rejected. Each of those messages now reads "Your message was not sent." beside its Retry. The match is made on typed facts, not on the words: the host writes the start's row and the rejection of its queued messages from the same failure fact, and the row is keyed by the start. The pane finds the loaded start-failure rows by that key and shortens a message's notice only when its loaded journal submission was rejected with the same fact. Any other rejection, or one whose submission or row is not loaded, keeps its full notice. The row key moves to a shared module so the host that writes it and the pane that reads it use one definition. * fix(native-chat): a chat whose history keeps failing to open retries less often A read the host kept refusing (its history store could not be opened right now) reopened every 750 ms for as long as the chat stayed open, about 40 opens every 30 seconds. Each reconnect now waits twice as long as the last, from 750 ms up to 30 seconds, and never gives up; the first read that delivers anything starts the wait over at 750 ms. A damaged history still stops reconnecting at once. Reset happens on a delivered read, not on connect: a local subscribe resolves before the host's open refuses, so resetting there would keep the 750 ms loop. * test(native-chat): the pane harness types its journal rows without a cast * test(native-chat): the admission test passes no start-failure rows to the notices * test(native-chat): import the journal types once * fix(native-chat): a remote chat reads again as soon as its host is back The read retry doubles its wait up to 30 s during an outage, and nothing reset it when the remote runtime reconnected, so the transcript could lag the reconnect by up to 30 s. The read now watches the runtime status store's contact-regained edges (hostContactEpoch for a same-runtime return, connectionGeneration for a new runtime session) and, when one lands, runs a waiting retry immediately with the wait reset to its base. * refactor(native-chat): build each failure sentence from whole pieces Every sentence agentSessionFailureWords writes is now assembled from a table of whole English pieces, so a reader can supply its own words for each piece. The host still fills them in English, byte for byte as before. * fix(native-chat): translate the failure sentences desktop notices show A refused start and a rejected message now carry their failure fact to the notice instead of its English sentence, and desktop words that fact through translate keys whose English defaults are the host's own pieces. The host keeps writing English into rows and reasons, and a host sentence with no fact beside it still shows as written. * fix(native-chat): the history pane says only that Orca keeps trying When the pane's title already says Orca couldn't open this chat's history right now, the line under it no longer repeats that the transcript could not be read; it says only that Orca keeps trying to load it. The pane with no named reason keeps its two-part line. * test(native-chat): type the failure pieces a refusal notice shares * test(native-chat): the Chinese failure words use no Japanese-only characters * fix(native-chat): every history pane that says it didn't load says only that Orca keeps trying A pane whose title is a code's own words ("This chat's history couldn't be loaded.") now gets the short retrying line too. Only the pane with no named reason keeps the two-part line. * test(native-chat): a provider's words with nesting and markup stay as written in a translated notice * fix(native-chat): French and Spanish say a withdrawn message was withdrawn before the agent began working on it * fix(native-chat): Japanese and Chinese notices run their sentences on without a space A notice joined its sentences with a space in every language, so Japanese and Chinese read "Claude 无法启动。 请重新发送消息。" with a stray gap after the full stop. The failure-sentence builder now takes the joiner alongside its words, and desktop joins in the UI language: no space in Japanese and Chinese (including a plugin pack that declares either), one space elsewhere. The host and the phone keep English, joined with a space as before. * fix(native-chat): a failed /clear or /compact says why in the app's language The line under the composer printed the host's English sentence although the result carries the typed failure beside it. It now words that failure the way the host does (the chat's agent and /clear for a failed /clear, nothing for /compact), in the app's language; an older host that sends no failure keeps its sentence. * fix(native-chat): Spanish says a rate limit, and Korean says a withdrawn message was never processed The Spanish retry notice said the agent hit a usage limit, a different thing from the rate limit the English names. The Korean withdrawn-message notice said the agent had not started, which reads as the agent not launching; it now says the agent had not begun processing the message, as the other languages do. * refactor(native-chat): one rule says which words already say the history didn't load * fix(native-chat): an image size limit says its unit the way the reader's language does * test(native-chat): the option picker's i18n stand-in knows the reader's locale, which a refusal notice now reads * fix(native-chat): a sentence a language pack left in English keeps its space Sentences were joined by the UI language: no space in Japanese and Chinese, one elsewhere. A plugin pack for a Chinese or Japanese variant that predates the failure words falls back to English for them, so a notice read "您的訊息未傳送。Claude couldn't start.Send your message to try again." Each gap now follows the sentence before it: none after a full-width 。!?, one space after anything else. The built-in Japanese and Chinese catalogs end every sentence in 。, so they read as before, and English is unchanged. Since the rule no longer needs the language, one joiner serves every surface and the failure-sentence builder no longer takes one alongside its words. * fix(native-chat): a failed /clear this build only partly understands shows the host's own sentence The line under the composer words a failed /clear or /compact from the fact the host sends beside its sentence. The reader drops any part this build cannot place, such as a refusal code a newer host added, and the rest of the fact can then give different advice: "Run /clear again." where the host said "Start a new chat to continue." When any part the host sent did not survive the read, the line now shows the host's sentence as written, the same as for a host that sends no fact. A fact this build reads whole is still worded in the app's language. * fix(native-chat): a failure fact this build reads only in part shows the host's sentence everywhere The previous check compared only a fact's top-level parts, so a known refusal code carrying a reason a newer host added still counted as read: the reader dropped the reason and the notice re-worded what was left, which can advise differently from the host ("Run /clear again." against "Start a new chat to continue."). One shared reader now answers whether this build read the whole fact: it reads the fact and keeps it only when the read equals what arrived, at every depth. Every place that chooses between wording a fact and showing the host's text uses it: the line under the composer after /clear or /compact, a rejected message's notice from the journal's fact, and the smaller copy a rejected message keeps for when its journal row is not loaded. Matching a rejected message to the start row that already says why still uses what this build can read, since that is identity, not wording. Facts this build reads whole are worded as before. * fix(native-chat): a failed /compact names /compact as its next step on desktop too The host names the command a failed start was waiting on, for /clear and /compact alike. The line under the composer re-worded only /clear with it, so a /compact whose start failed read "The agent couldn't restart. Send your message to try again." in the reader's language. It now words every command the host answers with the agent and the command the host used, so desktop English matches the host and French or Japanese keep /compact. * fix(native-chat): a /compact whose start failed no longer says the operation was not confirmed A /compact on a chat whose agent is not running starts it first, and that start takes a new lease, so the chat's fence moves before the command's reply arrives. The write settles as one for a fence this pane no longer shows, and the composer line read that as "Conversation operation was not confirmed." Such a write now says nothing there, as every other write already does: the chat's own start-failure row says why, and the command's message is rejected in the journal. The sentence it printed is gone from the catalogs. * fix(native-chat): keep the message outbox within its line limit after main's growth * fix(native-chat): a command's own reply is kept when its start moved the fence A /clear or /compact on a chat whose agent is at rest starts the agent first, and that start moves the chat's fence before the command's reply arrives. Every reply from an earlier fence was discarded, so a /clear whose new chat failed to start said nothing at all, and a /compact that started left "/compact" in the composer. A conversation command's reply is now kept while the pane still shows the chat it was sent for; a closed pane or another chat still drops it, and every other write keeps the fence rule. The line under the composer says nothing only when the failure is the chat's own start and that start's loaded row already says why, the rule a message that start rejected already follows. * fix(native-chat): a returned queued message shows the host's sentence for a fact read in part The caption under a returned queued message re-worded its failure from whatever this build could read of the fact. A newer host's fact with a refusal code or reason this build drops read as a shorter sentence with different advice. It now re-words only a fact read whole, and otherwise shows the host's own sentence, as every other surface that re-words a fact does. * test(native-chat): a command reply after any fence move, for /clear and /compact The fence-move tests now state the rule as the code has it: a conversation command's reply is kept whenever the pane still shows its chat, whatever moved the fence. They cover a /clear that completed, and a failed start for each command in French with the fact that command really meets (a /clear's new chat fails to start; a /compact's chat fails to restart). * fix(native-chat): only the reply a pane still waits on outlives a fence move A conversation command's reply was kept across a fence move whenever the pane still showed the same chat. A reply the pane had stopped waiting on, because it left the chat and came back or a newer command replaced it, was applied as if it answered the current one. The pane now remembers the one command request it waits on; only that request's reply is kept after the fence moves, and closing the pane or showing another chat forgets it. Tests now drive the fence move during the request itself rather than through /clear starting an agent, and cover a /clear that stops a running agent. * fix(native-chat): a newer-Orca history error keeps a whole retry line, and the phone shows the host's words for a fact it reads in part When a chat's history was saved by a newer Orca, the pane's title reads "Chats were saved by a newer Orca. Update Orca to keep using them." and the line under it said only "Orca keeps trying to load it.", with nothing for "it" to mean (in French and Spanish the pronoun also disagreed with "chats"). That title names every chat rather than this one, so the pane keeps the full line: "The transcript could not be read. Orca keeps trying to load it." On the phone, a returned queued message whose failure fact this build reads only in part was re-worded from what it could read, dropping advice the host gave; it now shows the host's own sentence, as the desktop card already does. The comment on the reply the pane waits on now says what forgets it: a newer command, or disabling the pane. * fix(native-chat): a command reply this build can't place shows the host's own words A newer host can answer a conversation command with a command name this build doesn't know. This build re-worded that reply from its failure fact as if it were a command it knew, naming the command in its own words, or said nothing when a loaded start row matched the fact. It now shows the host's sentence as written, as it already does for a fact it can read only in part.
…real ~/.codex (stablyai#23552) * fix(codex): a real-home restore leaves a file alone once someone else changed it Orca writes ~/.codex/hooks.json (and a trust rebase writes config.toml), then runs a Codex trust session for up to 10 s, then restores the original bytes if the session fails. The restore wrote unconditionally, so a save that landed during the session, from the user or another Orca, was silently reverted. Each restore now compares first: it writes the original back only while the file still holds the generation Orca's mutation left, and otherwise logs and leaves it alone. This covers the real-home install and opt-out sweep (restoreRealHomeHooksJson), the legacy sweep's hooks restore, and config.toml rollback (restoreCodexTrustConfig). For hooks.json the generation is the exact bytes Orca wrote. For a config.toml that a trust rebase changed it is the file as the rebase left it. When Codex itself wrote config.toml inside the session that just failed, Orca never knew those bytes, so that rollback compares against the file as the session settled. The next commit keeps other Orca instances out of that window; a user edit made during such a session can still be rolled back. * fix(codex): serialize real-home Codex writes across Orca instances Every Orca on one HOME (a dev and a packaged app, or an offline CLI) writes the same ~/.codex/hooks.json, config.toml and ~/.orca/agent-hooks/codex-hook.sh. The per-file lane that orders capture, mutate and restore was in-process only, so another instance could write inside this one's restore window, or undo it. The lane for the user's real config.toml now also holds the existing crash-safe managed-hook install lock (~/.orca/managed-hook-install.lock, the one relay installers take for the same home). It is taken only by the outermost acquire, because the lock file is not reentrant and grants and trust rebases nest inside an install. Managed-home installs, the real-home install and opt-out sweep, and the legacy sweep all enter through it. Compare-and-swap on restore stays as the backstop. A lock that cannot be taken within its 10 s wait fails that install, which is already best effort: launch prep logs it, and the real-home lane falls back to the managed lane until its retry. * fix(codex): opening a terminal no longer strips the shared Codex entry from ~/.codex Every Orca instance on one HOME writes the same status-hook entry into the user's ~/.codex/hooks.json, with its trust in config.toml. Launch prep runs on every pane spawn, and under a managed Codex account it ran the legacy system sweep. That sweep matched Orca entries by script file name, so it removed the current shared entry and the trust blocks the grant ledger recorded. On a live laptop hooks.json went 4139 -> 18 bytes about 150 ms before a new pane opened. With hooks off, the real-home lane's launch prep swept the same way. Now nothing automatic removes the current entry or its trust: - The legacy sweep removes only an enumerated list of retired command forms that no build writes any more (#1019's double-quoted form, #1536's exec-guarded form, and Windows' per-userData bare path), plus their trust. - ensureRealHomeCodexHookState with hooks off writes nothing; that covers launch prep, session resume and startup. - Only the user's explicit opt-out (codexHookService.remove()) strips the entry and its ledger-recorded trust from the real home. - The sweep-suppression gate existed only to stop the sweep from deleting the current entry, so it is deleted with its main-process wiring. Startup with hooks off already skipped the real-home install; with this change the first pane's launch prep with hooks off also leaves ~/.codex untouched. * fix(codex): a pane's prepare-codex only repairs a home its own HOME's app installed On macOS a pane starts through login(1), so it gets the user's real HOME even when its Orca app runs with another one. The pane's `codex()` preflight installed hooks in the CLI process with that real HOME: it rewrote ~/.orca/agent-hooks/codex-hook.sh, promoted trust into the real config.toml, and wrote the real HOME's script path into the app's managed home. The preflight now acts only when the managed home's hooks already run this process's own shared script, which proves the app that installed them shares its HOME. Otherwise it writes nothing; the app installed the home at spawn. Why not a no-op: the preflight was added (#14326) because trust can go stale between opening a pane and typing `codex`, for example in a pane that survives an app update, and Codex then stops in hook review. For a same-HOME pane it still repairs that. Why keep promotion: the install drops runtime trust the system config does not back, so skipping promotion would delete approvals the user gave inside Orca-launched Codex. * test(agent-hooks): await every installer in the refresher coverage test The test fired each managed installer without awaiting it and read ~/.orca/agent-hooks straight after. Codex's install now takes the cross-process real-home lock before it writes its script, so the script landed after the read. Await the installers, and stub Codex's trust sessions so the awaited install cannot start a real `codex app-server`. * fix(codex): retire the two real-home command forms the list missed The real-home lane wrote two Codex hook forms into ~/.codex that no build writes any more and that the enumerated retired list did not name: - POSIX, #9501 until #10885: the file-guarded form draining with a bare `cat`. - Windows, #9501 until #10221 took Windows off the real-home lane: the encoded PowerShell launcher for a non-cmd-safe script path. The file-name sweep removed both before; the enumerated sweep left them in place, trusted, still passing the script's exit status to Codex. Both now match as frozen literals. Also corrects the startup ordering comment: the real-home install runs first so its in-slot upgrade lands before the managed install's sweep retires the prior command; nothing re-arms a legacy sweep any more. * fix(codex): take the real-home lock only when a write is needed The previous commit made every entry to the real-home config lane take the cross-process lock. That lane runs on every pane spawn and every typed `codex` preflight, so the steady state paid an owner probe (a `ps` spawn on macOS) and could wait up to 10 s behind another instance's trust session, even though it wrote nothing. Each real-home writer now compares the desired state with the files on disk first, without the lock. Only when a write is needed does it take the lock, re-read and recheck, then write: - real-home install: the planned hooks.json, the shared script and the ledger-recorded grant are compared; the locked path re-plans from disk. - legacy sweep: locks only when a retired entry is present; the sweep re-reads. - approval promotion: locks only when there is something to promote; the promotions are recomputed under the lock. - the shared ~/.orca/agent-hooks script: locks only when its bytes differ. The explicit opt-out always takes the lock. The lock is reentrant through async context, since grants and rebases nest inside an install, so the config-lane option the previous commit added is removed. * fix(codex): a shared script without its exec bit is not the steady state The compare-first check matched the shared ~/.orca/agent-hooks script on bytes alone. writeManagedScript also restores 0755 on every call, and the POSIX hook guard skips a script that is not executable, so a script whose mode was lost (a dotfiles restore, a plain copy) now stayed that way: every Codex hook drained stdin and reported nothing until an app restart refreshed the script. The check now also requires the mode the writer sets, so that case takes the lock and the write path repairs it. * test(codex): the retired encoded launcher never matches today's shared one The shared encoded Windows launcher is still current for other agents, so the comment claiming today's launcher is never encoded was wrong. What keeps the retired matcher off it is the exact payload: since #14825 the shared launcher prefixes its payload and drops -ExecutionPolicy Bypass. Pin that with a case. * fix(codex): the pane step recognises its own script under a home path with an apostrophe The same-HOME check looked for the script path wrapped in bare single quotes, but both hook writers escape an apostrophe inside the quotes. A home such as C:\Users\O'Brien never matched, so the pane-step repair never ran there. * fix(codex): the trust-RPC escape hatch still keeps the real home off its lane The no-write check reported a recorded grant as current, so with ORCA_DISABLE_CODEX_TRUST_RPC set the real-home lane stayed in use. The grant itself refuses before reading its ledger; the check now does the same. * fix(codex): the shared script write no longer waits on the real-home lock The write is atomic and skips identical bytes; waiting behind another instance's trust session could only fail a pane's managed-home install. * fix(codex): an in-Orca approval survives a launch that cannot get the real-home lock The install drops runtime trust the system config does not back, so a promotion skipped for want of the lock lost the approval for good. It now writes unlocked, as it did before the lock existed. * refactor(codex): take the cross-process real-home lock back out The lock fixed no observed failure. The three that were observed each have their own fix in this series: the legacy sweep matches only frozen retired command forms, hooks-off launch prep writes nothing, and a pane's prepare-codex repairs only a home its own HOME's app installed. The lock instead brought its own defects: a steady-state spawn waiting behind another instance's trust session, a compare-first split to avoid that, a script write and an approval promotion that could fail for want of the lock. Removed, with their tests: the real-home write lock and its async-context reentrancy, the plan/compare split that kept it off steady-state spawns, the compare-first legacy sweep, the locked approval promotion and its unlocked fallback, the compare-first shared script write (writeManagedScript already skips identical bytes and restores the exec bit), and the CLI tsconfig entries the lock pulled in. Kept: the retired-forms matcher, the hooks-off no-op, removal only on an explicit opt-out, the pane own-script check, and the compare-and-swap rollbacks. Every instance now writes identical bytes idempotently. * fix(codex): an opt-out that cannot read hooks.json keeps Orca's trust and ledger The opt-out swept the real-home entry, then dropped Orca's ledger-proven trust whenever a ledger existed, even when the sweep could not read hooks.json. The entry could still be there, now untrusted, and the ledger that proves ownership was gone for the retry. Drop that trust only after a sweep that read the file. * refactor(agent-hooks): one predicate for whether an agent's status hooks are on "Global switch on and this agent not turned off" was spelled out separately in the startup controls, the settings reconcile, the retained-home reconcile, the WSL preflight RPC, the CLI preflight and the OpenCode plugin selection. They now share one function, in a module light enough for the CLI's per-launch Codex preflight to load. The PTY spawn env derives the Codex flag from the switch and opt-out list it already carries, the same way it does for OpenCode and Pi, instead of receiving a second copy. * fix(codex): launch and resume prep honour Codex's per-agent hook opt-out Turning Codex off in the per-agent hook settings removes Orca's Codex hook entry, but launch prep and session resume read only the global hooks switch, so the next Codex launch or resume wrote the entry straight back into the real ~/.codex or the account's home. Both now read the per-agent predicate, which the PTY spawn env and startup already honoured. * fix(codex): turning Codex off per agent clears the real ~/.codex entry While the real-home lane owns ~/.codex/hooks.json, the legacy system-home sweep stands down. That gate read only the global switch, so turning Codex off per agent ran remove() with the sweep still suppressed and left Orca's entry in the real ~/.codex. The gate now reads the per-agent predicate, the same as turning every hook off. * test(codex): cover the system ~/.codex sweep gate for Codex turned off The gate that lets the legacy system-home sweep run was an inline closure in startup, so reverting it to the global switch left CI green. It is now a pure function beside the gate it feeds, with a table test and a remove() test on a seeded ~/.codex: turning Codex off strips Orca's entry and keeps user hooks; with Codex on the entry stays. * fix(cli): keep the agent-status hooks predicate loadable by the packaged CLI The CLI's prepare-codex handler imported the predicate from src/main, but the Electron build rebuilds out/main from its declared entries only, so the packaged `orca agent hooks` commands could not load it (package jobs and the CLI bundle-parity test were red). The predicate reads only settings, so it now lives in src/shared, which the CLI compiles itself. * feat(codex): every Orca build writes one frozen Codex hook command The Codex hook command was built from this build's wrapper, so two builds on one HOME disagreed about the bytes of the shared ~/.codex entry and kept rewriting it, with a Codex trust session each time. The command is now fixed per form and carries its form number: - POSIX: one command with no path in it. It runs the shared script only in an Orca pane with hooks on (pane key and hook port set), drains stdin everywhere else, and always exits 0. A branch for a per-build script root is written now and stays dormant until Orca sets ORCA_AGENT_HOOK_ROOT, so that change will not move these bytes. - Windows: the bare forward-slash path to the shared .cmd, which runs under PowerShell 7 and 5.1, Codex's hook hosts. A profile path that is not one PowerShell token gets a plain PowerShell form with the same branches. The literals live in the form module, so a change to the shared hook constants cannot move them; goldens pin the bytes. Every form keeps `agent-hooks/codex-hook.*` in plain text, so older builds still recognize it. * fix(codex): one main-process owner adds the real-home entry; nothing restores files Each Orca writer of ~/.codex decided what Orca's entry must be from its own build and instance, then removed or reverted whatever differed: launch prep rewrote any Orca-shaped entry to this build's command and stripped Orca entries from events this build does not use, and a failed trust session restored hooks.json and config.toml from snapshots. With several instances and builds on one HOME, every disagreement became a deletion or a revert. The main process is now the one writer, and its writes are add-only: - A launch or resume adds Orca's frozen entry to an event that has none and leaves every Orca entry it finds, so a running older build is never fought. - App start also converts an older Orca form to the frozen command, once, in its own slot: one hooks.json write (one .bak) and one trust grant per home. - A newer form is never rewritten or appended beside, and Orca entries in events this build does not use are kept. - After a failed trust grant, only an entry this call wrote that is still untrusted is withdrawn, putting back the handler it replaced. Both files are re-read, so a concurrent edit, or the identical entry another Orca trusted meanwhile, survives. Deleted: the compare-and-swap hooks.json restore, the config.toml snapshot restore after a grant session and after a user-trust re-key, and the rollback module. A grant session writes trust only at Orca's own keys, and every caller settles those keys itself. A failed re-key of moved user hooks now keeps the write and reports it; Codex lists those hooks for review. * fix(codex): the pane CLI asks the app to prepare its Codex home `orca agent hooks prepare-codex` ran Codex's install inside the pane. That process can have the real HOME (login(1)) and runs outside the app's in-process queues, so it was a second writer of ~/.codex and ~/.orca beside the app. A check that the home ran "its own script" guarded it. The pane step now only asks the app, over the same kind of local RPC the WSL pane step already uses (agentHooks.prepareCodexForPane). The app checks that the pane's CODEX_HOME is one its own userData owns, reads its own hooks setting, and installs on its own queue. An app that is not running, or is too old to know the method, makes the step a no-op, as it is on WSL. The own-script check and the CLI's settings read are gone, and the preflight module leaves the CLI bundle. * fix(codex): delete the pane step on native hosts The previous commit had `orca agent hooks prepare-codex` ask the app to prepare the pane's Codex home. The case it existed for (#14326, a pane that survives an app update with stale hook trust) did not reproduce, and no other desktop agent host writes agent config from a terminal or launch wrapper. - Deleted: the agentHooks.prepareCodexForPane RPC method, its params and catalog entry, and prepareManagedCodexHomeBeforeShellLaunch with its module, tests and CLI build entry. - `agent hooks prepare-codex` is a no-op on native hosts. It stays for one release so shell wrappers from older builds, which still call it, exit 0. - WSL panes are unchanged: they still ask the app over agentHooks.prepareCodexForWslPane. The shell wrappers and ORCA_CODEX_LAUNCH_PREFLIGHT stay, because WSL panes use the same wrappers and variable (forwarded through WSLENV). A native pane still starts the CLI once per `codex` it runs; skipping that is a follow-up. * test(codex): a failed trust session keeps concurrent edits to both files QA case 9 at host level, on a real file system in a temp HOME: Codex's trust session fails after another writer saved hooks.json and config.toml. - Both saves survive, and no Orca entry is left that Codex would list for review: this call's entry is withdrawn. - A failed one-time conversion puts the older Orca entry back in its slot and keeps both saves. Both tests fail on the previous head, which restored config.toml from a snapshot and left the untrusted entries in hooks.json. Removing the withdrawal turns both red. * feat(codex): read whether an Orca entry's stored trust is still current A Codex release that changes how it hashes a hook leaves Orca's stored trust stale: the entry is present, but Codex lists it as modified. Checking only whether the entry is missing cannot see that. readOrcaEntryTrust sorts a present entry into four states: - trusted: the stored hash is the current one; - untrusted: there is no stored hash; - stale: the stored hash is not the current one; - disabled: the user turned the entry off. The caller can pass Codex's current hash, for example one a grant recorded. The failed-grant withdrawal now uses it, and also keeps an entry the user turned off. Nothing re-grants on 'stale' yet. * fix(codex): a slow Codex start retries on the next launch, never for minutes On a loaded Mac a cold `codex app-server` took over 10 s (QA case 4). The grant timed out, the entry was withdrawn, and a 5-minute cooldown in both the grant and the real-home install then refused every retry. - The native session deadline is 30 s, the same as WSL's. - A timeout starts no cooldown in the grant or in the real-home install. The next launch retries. Other failures keep their cooldown. - Launches that queue behind a slow session share one follow-up run, so a launch waits for at most two sessions, not one per earlier launch. Tests: a 15 s cold start still grants and keeps the entry; after a timeout, the next launch runs a session at once; four queued launches run two sessions. Each is red on the previous head, and each mechanism was removed in turn to confirm its test turns red. * fix(codex): Orca's automatic writes never move a user hook Codex keys a hook's trust by its position in hooks.json. App start's collapse of Orca duplicates removed every Orca entry and appended one at the end. That moved any user hook that followed a removed entry, so the write waited on a session to re-key the moved hook's trust. App start now: - converts the first Orca entry that sits in a plain slot to the frozen command, in place; - drops any other Orca entry only when that moves no user hook; - keeps a duplicate that a user hook follows, and trusts every frozen copy, so none is listed for review; - appends only when no frozen entry is left. Tests check user positions and user trust blocks byte-for-byte for each automatic write: add-missing (append), the one-time conversion (in place), a trailing duplicate, a duplicate before a user hook, and older duplicates normalized to one entry. The three collapse cases fail on the previous head. Removing the position check, or the in-place conversion, turns its tests red. Only the explicit opt-out still removes an entry that user hooks follow. * fix(codex): removing an Orca entry never waits on a Codex session Removing an Orca entry from ~/.codex/hooks.json moves every user hook behind it up a slot, and Codex keys trust by slot. The retired-form sweep, the opt-out and a failed-grant withdrawal all asked a `codex app-server` session to list the old trust before writing, and to re-key it afterwards. A timeout there threw before the write and latched a 5-minute cooldown, so a slow cold start blocked the retired-form sweep at boot (QA case 4). Each moved hook's [hooks.state] block now moves to its new key, body bytes unchanged, straight after the hooks.json write. Codex hashes a hook's content, not its position or its file path, so the moved block stays exactly as valid as it was: a trusted hook stays trusted, an untrusted one stays untrusted, and one the user turned off stays off. No removal waits on or depends on a session. A failed config.toml write keeps the hooks write and logs. Deleted: the inspect and repair sessions, their client, and their cooldown. The generation guards on the hooks.json writes stay, for other processes. Tests: the retired sweep removes the retired entry and carries the trust of the user hook behind it while every Codex session times out (red on the previous head); the opt-out carries an appended user hook's trust; the move carries trusted, disabled and untrusted states byte for byte. Removing the move turns all of them red. * fix(codex): a Codex launch never waits on Codex's approval of Orca's entry A launch on the real-home lane awaited Codex's trust grant for the entry it had just added. A cold `codex app-server` on a loaded Mac took over 10 s, so the launch could wait that long, and a failure then latched a 5-minute cooldown. - Codex's approval runs in the background, with a 30 s cold-start budget. - A launch uses the real home only when the ledger shows trust is already current. Otherwise it goes to the managed home at once, and the next launch picks up the finished grant. - A launch that arrives while a grant runs does no work and does not queue behind it. - A resume into the real home has no managed home to fall back to. It waits for the grant, but no longer than the 10 s a launch always could. - A background grant that times out starts no cooldown; the next launch retries. Any other failure backs off for 10 s instead of 5 minutes. Success is what the ledger remembers. - A failed grant still withdraws only what that install added and is still unapproved. The log now says how many entries it took back and when the next try comes. Managed-home grants keep their 10 s deadline and stay on launch prep, as before; they fall back to Orca-computed trust. Tests: - A 15 s start: the launch returns in under a second on the managed home, a second launch starts no session, the grant lands in the background, and the next launch uses the real home. - A timeout sets no cooldown, withdraws its adds and logs it. - Another failure retries after 10 s, not before. - A resume waits only as long as allowed. - Case 9 checks the log line and the retry. Making the launch await the grant, a 10 s budget, either timeout cooldown, and a 5-minute backoff were each tried, and each turns its test red. * fix(codex): move a hook's trust only when every stored key has the known shape Orca now edits Codex's trust store directly when a removal moves a user hook. Three safeguards keep that honest: - Fail safe. If any [hooks.state] key in config.toml does not have the shape `<path>:<event>:<group>:<handler>`, nothing moves and Codex asks the user to review. That shape was checked unchanged from Codex 0.141 to 0.158. - Targeted. The file is read immediately before the atomic rename, and only the moved keys' blocks change. Every other byte stays, and no snapshot is restored. - Verbatim. Each block's body moves as Codex wrote it, including fields Orca does not know. No hash is ever computed, and a hook with no block gets none. Tests: - An unknown key shape stops every move. - Everything except the moved block survives byte for byte, and the moved body keeps an unknown field. - In case 9, a hook the user approved during the failed session keeps its approval when the withdrawal moves it, beside the concurrent project edit. Removing the shape check, or writing a computed block instead of the stored body, turns these tests red. * refactor(codex): keep only the trust read the failed-grant withdrawal uses A capture across Codex 0.141, 0.150 and 0.158, switching in all six directions, showed Orca's entry keeps the same hash and stays trusted. A Codex upgrade does not make its trust stale, so nothing needs to re-grant on staleness. readOrcaEntryTrust keeps the four states the withdrawal needs, but loses the parameter that let a caller pass a different current hash, and the test for a Codex that hashes differently. * fix(codex): native panes no longer start the Orca CLI before each codex The pane step is a no-op on native hosts, but native panes still carried ORCA_CODEX_LAUNCH_PREFLIGHT, so every `codex` typed in a pane started the Orca CLI for nothing. Only a packaged Windows build's WSL pane now gets the variable; the app prepares every native Codex home itself. The resolver loses the dev-launcher path and its userDataPath option, which only native panes used. Tests: a native macOS, Linux and Windows pane gets no preflight, packaged or not, even with the bundled CLI present; a WSL pane still gets the verified absolute launcher. Letting native panes through again turns them red. * chore(cli): say when the native prepare-codex no-op can go Native pane wrappers from builds up to v1.4.216 still call it. It can be deleted once no supported build's wrapper does. * test(codex): check the WSL launcher path instead of asserting it * fix(codex): a launch no longer waits behind the background real-home approval The background grant ran its whole codex app-server session inside the shared ~/.codex/config.toml lane, and on a cold host its session was also the shared capability probe. A launch sent to the managed home then waited on both: the managed install and the project-trust write queue on that lane, and the managed install's own grant waited for the probe. On a cold app-server that was up to 30 s per launch. The lane was held across the session only to protect the retired capture-and-restore. Codex writes its own records, so the lane is now taken only around Orca's own pre-grant write. The background grant runs its session without publishing it as the shared probe, and the whole grant is bounded by its deadline, so a hang outside the session cannot leave the lane 'granting'. * fix(codex): a failed re-grant no longer strips Codex's own approval of Orca's entries Before each trust session, the grant deleted every Orca record whose hash matched the one Orca computes. That exists because a managed home's fallback writes Orca-computed trust under both Windows path-separator spellings, and Codex rewrites only its own spelling, so the other copy would linger. On failure the managed and WSL fallbacks write that trust back, and before this fold a snapshot restore covered it. The real ~/.codex has neither: Orca never writes computed trust there (the real-home lane does not run on Windows at all), so a matching record there is Codex's own approval. After a ledger miss (another Orca profile, a Codex update, a lost ledger) and a failed session, nothing put it back, and every Orca entry showed "Hooks need review". The clear now runs only for homes whose fallback writes that trust. * fix(codex): a real-home resume spawns only once Orca's entry is approved or withdrawn A resume that must run in ~/.codex waited at most 10 s for the background approval, then spawned anyway. On a cold app-server that left Codex beside an unapproved Orca entry, so the resumed pane showed hook review. The resume now waits for the grant to settle. Settled means Codex approved the entry, or the grant failed and withdrew its own unapproved write; the grant's deadline bounds the wait (30 s, the cold-start budget), and a failed approval never fails the resume. Why this over the alternatives: - Spawning at 10 s keeps the review prompt this fold exists to remove. - Withdrawing at 10 s from the resume races the still-running session: Codex can write the frozen entry's hash after the withdrawal, and for a converted entry that marks the older command Orca put back as modified. - A resume cannot use the managed home: the session lives in ~/.codex. So the only states that cannot race Codex are the grant's own settle. The cost is a longer worst case on a cold app-server (up to the 30 s deadline, plus any managed-home install that holds the config.toml lane); a warm approval takes seconds, and an approved entry costs no wait. * fix(codex): keep the 5-minute trust cooldown for launch-path grants The fold shortened the host's trust-grant cooldown from 5 minutes to 10 seconds for every grant. That was meant for the background ~/.codex approval, which blocks no launch. The managed-home and WSL grants run inline on the launch path, so with a hung app-server every launch more than 10 s after the last failure paid the full inline timeout again (10 s native, 30 s WSL). Cooldowns are now kept per lane: inline grants keep 5 minutes, the background grant retries after 10 s, and neither lane's failure cools the other down. A success, or a proven-missing surface, still clears both. The real-home install's own retries (an unreadable hooks.json, unknown keys) are back on the 5-minute interval they had before the fold. The cooldown moves to its own module so the grant stays within the file limit. * fix(codex): a failed grant withdraws the exact copy it wrote The withdrawal re-found "this call's" entry by command, taking the first frozen handler in the event. When app start converted a later slot while an earlier frozen copy sat in a matcher group (which conversion skips), a failed grant acted on that earlier copy: it put the older command into it, or skipped it, and left the converted, unapproved copy in place. Each write now records where its handler landed, after any duplicate drops, and the withdrawal acts only on that slot. A copy that has since moved is left alone; the next launch's grant retries it. * fix(codex): the failed-grant withdrawal checks hooks.json is unchanged before writing The install and the retired-form sweep both refuse to replace ~/.codex/hooks.json if it changed since they read it. The withdrawal did not: a save landing between its read and its atomic replace was lost. The window is small, since the withdrawal is synchronous, but it now carries the same guard. * refactor(codex): drop rationale left over from the snapshot restore; name the trust-move module for what it does Comments on the config.toml lanes still justified them by a grant's capture-and-restore window, which the fold deleted, and the trust-write deadline still counted a grant session holding the lane. They now give the reason that remains: Orca's own multi-step reads and writes, and managed-home installs that hold the lane across their inline grant. codex-user-hook-trust-rebase no longer rebases through Codex; it moves stored trust records, so it is now codex-user-hook-trust-moves. The grant test that pinned two sessions on one config.toml to run one at a time is removed: its reason was an interleaved capture and restore. Callers that write config.toml around a grant hold their own lane, which the nested installer test still covers. * build(cli): list the trust-grant cooldown module in the CLI program The CLI's agent-hooks handler loads the hook controls, which reach the Codex trust grant; the CLI project is composite, so every module in that graph must be listed. * docs(codex): say which Windows hosts each hook command form runs under Codex runs a hook under the turn's shell (PowerShell 7 or 5.1 in every captured session) and, with no single local turn shell, under %COMSPEC% /C. The bare forward-slash path ran under all three in the Windows host census. The PowerShell form used for a profile path with a space does not parse under cmd.exe; no form valid in all three hosts has been run for such a path, so the form stays and the gap is stated here and in the PR. * test(codex): type the withdrawal seam without an assertion * fix(codex): a real-home resume starts at once, trusting Orca's entries for that process A resume that must run in ~/.codex waited for Codex's background approval of Orca's newly written hook entry: up to 30-40 s on a cold app-server. That made the user's resume wait on bookkeeping, and the alternatives (start at 10 s with Codex's hook review showing, or withdraw the entry and race Codex's own write) were worse. Codex reads hook trust from its session-flag config layer as well as the user's config.toml, merged per key, and has since hook trust shipped. So the resume no longer waits. When Orca's own frozen entries in ~/.codex are untrusted (or hold a stale hash), the resume command carries `-c hooks.state={'<key>'={trusted_hash='<hash>'},...}` for exactly those entries: the key under both the logical and the real path of ~/.codex (Codex keys an explicit CODEX_HOME by its real path), and the hash of that entry's content, so it can trust nothing else at that slot. The user's hooks are never included, nothing is written, and the background approval still runs for later plain `codex` launches. An approved entry adds nothing; a Codex known to lack hook trust gets nothing. One inline table, because Codex splits a `-c` key on every `.` and the key holds `.codex/hooks.json`. TOML literal strings keep `"` out of Windows native-argument quoting. The flag goes before `resume <id>`, quoted for the pane's shell (portable Unix, PowerShell or cmd), in the launch command and in the setup-sequenced copy of it; a cmd line whose path cmd would expand, or a key with an apostrophe, is left unchanged. SSH and WSL resumes get no preparation, so no local path reaches them. * Revert "fix(codex): a real-home resume starts at once, trusting Orca's entries for that process" This reverts commit 1bd30651d65fa33f5feda0e2c65b6b95d39c1368. * fix(codex): a real-home resume starts at once, without waiting for approval A resume into the real ~/.codex waited until the background approval settled, up to its 30 s deadline on a cold app-server: bookkeeping for later launches gating the resume the user asked for. It now starts at once. If the approval is still running, that first resume can show Codex's hook review once; the approval then lands and later resumes and plain codex launches are trusted. Trusting Orca's entries per process was the alternative, but the resume command is typed into the pane's shell, and hook settings stay out of typed commands. * test(codex): read real-home hook groups with the installer's own type * fix(codex): a background approval is bounded only by its session's own deadline Review loop 2, L3. grantWithinDeadline raced a second 30 s timer against the background approval. Loop 1 added it so that a hang upstream of the session could not leave the lane 'granting' forever. That hang cannot happen. The only caller is the native real-home grant (its plan is always host 'native'; the real-home lane is off on Windows, so WSL never reaches it). Everything before the session is synchronous there: command resolution and binary stamp, the ledger read, the state-db backfill check, the capability and cooldown checks, and runUnshared awaits no shared probe. A synchronous hang would freeze the main thread, which no timer can rescue. The session itself starts a kill timer right after spawn (runCodexAppServerSession), with the same 30 s, and it kills the app-server tree when it fires. So the outer timer was a second copy of that bound. Because it started first, it won by the spawn time. It then settled the lane and cleared backgroundGrant while the app-server was still alive, and the next launch could start a second concurrent session. It abandoned the session rather than cancelling it. Deleted, not moved: the session's own timer is the one bound, and it cancels. Test: codex-real-home-slow-app-server.test.ts "runs one session at a time, ended by its own deadline". The fake session starts its timer after a simulated spawn, as the real one does. A launch at 30 s finds the session still running and starts none; the lane settles when the session times out. It replaces the "settles a grant that never answers" test, whose never-answering session could not time out at all. * fix(codex): a background approval's retry has one schedule, the real-home lane's Review loop 2, L4. A non-timeout background failure set two 10 s schedules for one failure: the real-home lane's installRetryAfterMs, which gates ensure, and a `<host>#background` cooldown in the grant module. ensure's gate always tripped first, so the second one was consulted only after something reset the first (turning hooks off). Then it answered 'retry-cached', which wrote the entry into ~/.codex/hooks.json only to withdraw it again: churn, not protection. Background plans now neither start nor consult a grant-module cooldown. The real-home lane (installRetryAfterMs) is the one source of truth for when a background approval runs again, and its 10 s interval moves into codex-real-home-background-grant.ts, the module that sets it. The cooldown module is back to one host-keyed map for launch-path grants, with the same 5-minute interval as main. A success or a proven-missing surface from either lane still clears the host's cooldown. Tests: - codex-hook-trust-grant.test.ts "neither starts nor waits on a cooldown for a background grant": two failing background grants each run a session and leave no cooldown; an inline failure still cools down inline grants and not the background one. - codex-real-home-slow-app-server.test.ts "has one retry schedule: turning hooks off and on after a failure retries at once": after a failed approval, hooks off then on runs a session and installs, instead of a retry-cached write-and-withdraw. * fix(codex): hooks turned off and on during an approval re-add Orca's entry Review loop 2, L1. ensure returned at once whenever a background approval was running, whatever the lane. Turning hooks off during an approval sets the lane to 'removed' (usable), so turning them back on returned 'removed' without re-adding the entry. Launches in that window spawned in ~/.codex with no Orca hook and got no status for their lifetime, for up to 30 s, until the approval settled and a later launch re-added it. ensure now returns early only while the lane is 'granting', which is what the early return exists for: a launch never waits on Codex's approval and uses the managed home until it lands. Any other lane runs the normal add-missing install. That install can start a second approval while the first is still running. Approvals are now chained, so Codex still runs one session at a time, and a finished approval clears the handle only if it is still the latest one (before, an older approval's finally could clear a newer one's handle). The older approval's result is already dropped by the lane generation check. Test: codex-real-home-slow-app-server.test.ts "re-adds the entry when hooks go off and on during an approval, one session at a time". While the approval hangs: opt-out removes the entry; re-enable re-adds every entry, keeps launches on the managed home, and starts no second session; once Codex answers, the lane is installed and every entry is approved. * test(codex): a launch during the real-home approval shows what it waits on Review loop 2, M2. The launch test's fake Codex failed every managed-home session at once with ENOENT, so the managed home's own approval was an instant "unsupported" fallback, and the test could not show that a launch sent to the managed home still waits on that home's inline approval when its ledger misses (first use, a Codex update, a lost ledger), up to 10 s, as on main. Now the managed-home session behaves like a real one: - "settles on the managed home with its hooks and the project trust written": the managed app-server answers; two launches settle in under 2 s while the real-home approval hangs, and the second launch finds the managed approval in its ledger (one managed session). - new "waits up to the managed home's own 10 s approval when that home is cold too": the managed session fails at its own deadline, as the real one does. The first launch is still pending at 9.999 s and settles on the managed home at 10 s; the request asked for 10 s. The next launch settles at once, because the failed inline approval cools down for 5 minutes. No product change. * refactor(codex): the managed and WSL installs own their pre-approval trust clear Review loop 2, L7. Before a Codex approval session, a managed or WSL home clears the approvals Orca itself computed, because on Windows its fallback writes them under both path spellings and Codex's canonical key may not overwrite the other one. The fallback writes them back if the session fails. ~/.codex has no such fallback, so there the clear would only delete Codex's own records (loop-1 H2). The grant module carried this as a plan flag, fallbackWritesSelfComputedTrust, and took the config.toml lane around the clear itself. The reviewer proposed moving the clear into the two callers. A literal move, clearing before the grant call, is NOT behaviour-neutral, so this does not do that: - The grant first checks its ledger, which compares the stored hash with the one Codex recorded. Codex's hash equals Orca's computed one (the premise of readOrcaEntryTrust), so a clear before that check deletes exactly the record the ledger proves. Every managed launch would then miss the ledger and run an inline session (up to 10 s). - Checked, not inferred: with the clear moved before the call in the managed install, codex-launch-during-real-home-grant.test.ts "settles on the managed home..." fails (2 managed sessions instead of 1). Log: ~/orca-qa/codex-real-home-leak/fb6/l7-literal-move.log What this does instead: each caller passes its clear as the grant's `beforeSession` step, which the grant runs only when a session will actually run (after a ledger miss, and not on a cooldown or cached fallback), exactly where the flag ran it. So: - the flag and its "never set for the real home" rule are gone; the real-home grant passes no step, so the grant module has no path left that deletes a trust record in ~/.codex; - the grant module's own lane acquisition around the clear is gone. It was always a pass-through: both callers already hold that file's lane (the managed install holds the runtime and system lanes, the WSL install holds its config.toml lane) across the whole grant. No behaviour change. The loop-1 probes still pass as fixed: trust-strip prints every entry trusted after a failed re-grant, and lane-hold prints managedInstall=settled projectTrust=settled. Tests (codex-hook-trust-grant.test.ts): - "removes equivalent Windows fallback keys before the RPC writes canonical trust" now passes the managed caller's step; - new "runs the caller's pre-session step only when a session runs": the step runs once for a session and not on the ledger hit after it. * chore(codex): comments stop describing a lock held across the session, or a rollback Review loop 2, L6 comment sweep (comments and one test name only): - codex-trust-config-concurrent-launch.test.ts: the test named "does not let a failing launch roll back a concurrent launch" said the per-file lane was the only thing left and that the doomed run's rollback must not resurrect the file. There is no lane across a session and no rollback now. Retargeted to what it covers: "leaves a concurrent grant's records in place when a sibling grant fails" (a restore would still turn it red). - codex-trust-grant-ledger.ts: "a grant session blocks launch prep" is true only of inline grants; the background one still costs an app-server start. The drift clause no longer says "before the pane launches", which is false for the real home. - agent-trust-write-deadline.ts: a stray hard wrap. The install.ts:105 comment was fixed with L1. A sweep of src/main/codex, src/main/startup, src/main/agent-hooks, the trust presets and the CLI handlers for rollback, restore, rebase, capture/restore, and a lane held across a grant or session found nothing else stale; the remaining "no restore" comments state the current rule. * fix(codex): a real-home resume waits for the one running approval, up to its 30 s limit Review loop 2, M1; coordinator ruling. A resume into ~/.codex has no managed home to fall back to. 5a737261d8c let it start at once beside an Orca entry still awaiting Codex's approval. Codex's TUI then shows a full-screen hook-review picker before the session and waits for keys: "Trust all and continue" also trusts the user's own unreviewed hooks, and "Continue without trusting" leaves that session with no Orca status for its whole life, because Codex does not reload hooks when Orca's approval lands later. Panes restored at app start after an update hit it too, since the start-time conversion leaves every entry awaiting approval. The resume now waits, but only while Orca's entry in ~/.codex is written and a grant is approving it (lane 'granting'). Every resume waits on that same in-flight grant: ensure never starts a second one while the lane is 'granting', so panes restored together share one session. The bound is the grant's own session limit (30 s). The grant settles only after Codex approved the entry, or after it withdrew its own unapproved adds, so the resumed session starts either trusted or with no Orca entry: never beside an unapproved one, and no picker. On a withdrawal that session has no Orca status, as on main after its 10 s wait. A failed approval never fails the resume. Tests (codex-launch-during-real-home-grant.test.ts): - "waits for a warm approval, and spawns with the entries approved"; - "spawns at the approval session limit with Orca entries withdrawn" (fake timers: pending at 29.999 s, spawns at 30 s with no Orca entry); - "makes panes restored together wait on one approval session" (three resumes, one session, all settle once it lands). codex-launch-per-agent-hook-opt-out.test.ts: a resume into ~/.codex awaits the approval; a resume into a managed account home does not. * fix(codex): repeated background approval timeouts back off, growing to 5 minutes Review loop 2, M3; coordinator ruling. A timeout of the ~/.codex approval starts no cooldown, so the next launch retries at once. On a host where codex app-server never starts within 30 s, every launch then wrote Orca's entry into ~/.codex/hooks.json, withdrew it again, and started another 30 s session, for the rest of the process: an unbounded retry with no exit. After 3 timeouts in a row the retry now waits 10 s, then 1 minute, then 5 minutes for every later one. The first two timeouts still retry on the next launch, so a slow cold start is not punished. Any other outcome ends the streak (a success, or any other failure, which keeps its own 10 s wait). The streak lives only in memory, so every app start begins at zero and a slow boot can never latch. Tests (codex-real-home-slow-app-server.test.ts): - "backs off after three timeouts in a row, growing to 5 minutes, and a success resets it": the first two timeouts retry at once, then 10 s, 1 min, 5 min, 5 min; after a success, a fresh approval gets two immediate retries again and a 10 s backoff after the third; - "keeps trying after timeouts during a slow first start, once the app server answers": three timeouts, then the next attempt at 10 s installs. * refactor(codex): one approval at a time, decided under the config.toml lane The real-home check kept a lane label, a generation stamp, a promise chain of ensures and a chain of approvals, and decided from the label at call time. Concurrent resumes from any state other than 'granting' each started their own approval (N x 30 s), a chained approval ran a plan an earlier failure had withdrawn, a hooks-off check during an approval released a waiting resume beside unapproved entries, and an app-start conversion during an approval was dropped. Now each check is one step under the real config.toml lane: an approval in flight answers 'approving' (unusable), hooks off answers 'removed', an open retry window answers 'unavailable', and otherwise the unchanged install runs and starts at most one approval. The approval settles under the lane: it withdraws its own unapproved adds on failure, sets the retry, and derives the verdict from the settings and the outcome, then runs an owed conversion. A resume waits only while an approval runs and an unapproved Orca entry is on disk. The opt-out sweep moves verbatim into its own module. * fix(codex): only a success or app start resets the approval timeout streak The ruling is that three timeouts in a row back off, and the count resets on success and at app start. A non-timeout failure or an unexpected error also reset it, so a host alternating those with timeouts never backed off. * fix(codex): a Windows profile path the shells cannot carry bare runs through cmd.exe The Windows hook command was the bare forward-slash script path, or, for a profile path that is not one PowerShell word, a PowerShell script. That script cannot parse under cmd.exe, which Codex uses when a session has no single local turn shell, so such a profile got no status there. A path of only letters, digits and _ . : / ~ - stays bare. Any other path, including one with a space, & ^ $ ` ' ! ( ) or a non-ASCII character, is written as cmd --% /d /c @"<path>", which ran under PowerShell 7, Windows PowerShell 5.1 and cmd.exe for each of those characters with a real Codex 0.158.0. The choice depends only on the path, so every build on a machine writes the same bytes. A machine holding the earlier PowerShell spelling converts it once at app start. * build(cli): list the real-home hook sweep module in the CLI program * fix(codex): the Windows cmd spelling names the system cmd.exe and turns off delayed expansion A profile path the shells cannot carry bare was written as cmd --% /d /c @"<path>". Under Codex's cmd.exe host the outer cmd.exe resolves a bare `cmd` from the hook's working directory first, so a repo holding cmd.bat (or .cmd, .com, .exe) at the session cwd would run on every hook event. And with delayed expansion turned on in the registry, a `!` in the path was dropped. The spelling is now <SystemRoot>/System32/cmd.exe --% /d /v:off /c @"<path>", unquoted (PowerShell reads a quoted first token as an expression) and with forward slashes. The Windows directory comes from %SystemRoot% when written, else from the directory above %ComSpec%'s System32, so both give the same bytes; if neither is a drive-absolute path it can spell unquoted, it is C:/Windows, which is still absolute. The bytes stay a pure function of the profile path and that directory, so every build on a machine writes the same command. Safe profile paths keep the bare path. Older Orca forms, including the bare-cmd spelling, convert once; the new spelling is never swept as retired. * refactor(codex): an approval's settle runs no deferred conversion An app-start conversion that arrived while an approval ran was remembered and run by that approval's settle. The settle then rewrote an older entry in place, unapproved, and started a second approval inside the same wait that releases every resume, so a resume could start beside an entry Codex would put up for review. That path could not happen: the only conversion caller is app start, and it is the process's first check, so no approval can be running when it arrives. The deferral and the settle's second check are deleted. A conversion that met an approval would now be skipped until the next start, and the test for this case pins that the settle writes nothing new and runs one session. * fix(codex): an approval's settle keeps a failed opt-out's verdict and ends only its own flight With hooks read off, an approval's settle always concluded 'removed', which the routing check treats as usable. If an opt-out during that approval could not read hooks.json, it had concluded 'unavailable' because the entry may still be there, and the settle overwrote that. The settle now keeps 'unavailable' when hooks are off; the next hooks-off check or opt-out re-derives it as before. The settle's fallback when it cannot run now clears the running approval only if it is still its own, and the routing check's comment states its rule: never usable while an approval runs. * fix(codex): spell the system cmd.exe with backslashes Under Codex's cmd.exe host the outer cmd.exe hands the typed program text to the child verbatim, and cmd.exe scans its whole command line for switches, so a forward-slash C:/Windows/System32/cmd.exe is read as switches: the hook never runs ("The syntax of the command is incorrect.") and /d is lost. Measured live on Windows; both PowerShell hosts rewrite argv0 and were unaffected. The script path after @" keeps forward slashes. * chore(codex): say why the cmd.exe path is absolute, as measured on Windows * test(codex): Windows managed-install tests expect the frozen command They still asserted main's PowerShell text and a backslash bare path; they only run on Windows, so nothing here caught it. Also correct the /v:off comment: a lone ! is never dropped, only a !NAME! pair expands. * ci: run the Codex managed-install tests in the Windows job Its Windows-only cases skip everywhere else, so nothing ran them; three of them still asserted a command this branch no longer writes. * ci: a change to the Codex managed-install tests starts the Windows job Also say what the missing-script case asserts: a non-zero exit, which PowerShell reports as 1. * chore(codex): name the hook trust key pattern for what it matches * test(codex): the managed-install tests remove folders with the retrying helper Now that they run in the Windows lane, a raw recursive rm there can throw EPERM after the assertions pass. * refactor(codex): one Codex hook-trust key pattern for the trust move and #23958's carry * test(codex): the trust move carries a block in Codex's quoted spelling and leaves no second table
… database (stablyai#24006) * fix(native-chat): report a failed startup chat reconcile instead of failing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none). * fix(native-chat): read restored chats without waiting on lease bookkeeping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads. * fix(native-chat): keep every record-store write off the startup chat read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes. * test: open, seed and read the agent-session record store through one harness Tests that open the durable agent-session record store, seed it, or read back what it persisted now go through agent-session-record-store-test-harness.ts instead of calling AgentSessionRecordStore.open or touching agent-sessions.json themselves. A later change that moves the store into the chat database then changes the harness instead of every test. No production code changes. Tests whose subject is the JSON file itself (its .bak recovery, salvage, schema versions, permissions, and what older builds read back) keep reading and writing the file directly; the storage move rewrites or deletes them. * fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed. * docs(native-chat): correct restore comments and a test name to match the final design * test: address the record-store harness by the host's state directory The harness took the store's own folder, so each caller picked one (join(root, 'store'), or 'agent-sessions' where a test read the store the runtime owns). A later change that moves the store into the state directory's journal database could not tell those apart, and would have had to edit every caller again. Every harness function now takes the state directory, the one the test's journal database and recovery capsule already live in, and keeps the store in the same subfolder the runtime uses. Callers pass that directory; store-only tests pass their temp directory unchanged. Format tests that share a directory with harness calls take the file path from testAgentSessionStoreFilePath. The folder name moves from a private constant in the runtime to AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness shares it without importing the runtime. Its value and every path built from it are unchanged. * refactor(native-chat): keep agent-session records in the chat journal database The record store's records, operation ledger, retired claim keys and chat tab index become tables in agent-session-journal.db (user_version 4). The version-4 migration copies agent-sessions.json in its own transaction and never writes, renames or deletes that file or its .bak. Each store write is one journal transaction over exactly the rows it changed, checked with the load rules; the file lock, the external-change refresh and its hash, the .bak rotation, salvage and the hot-path recovery fence are gone from the store. * wip: importer tests * test(native-chat): cover the records migration, the import, row writes and read-only records * docs(native-chat): retire comments that describe the records file as the live store * test(native-chat): drop the record-store harness's leftover file path and type the import fixture * test(native-chat): let the host harness cleanup wait out a recovery-offer read's lock * fix(native-chat): let Stop reach the agent when its ledger row cannot be written Stop's operation-ledger row now shares the database with the chat history, so damage, a full disk or a stranded transaction on that write refused the Stop before the interrupt. A cancel plan now takes its decision from the committed ledger in memory, runs without settling, and warns that the row was skipped. Other mutations answer proven damage with the typed "Unable to load this chat." refusal instead of the raw SQLite error. * fix(native-chat): answer whether a profile holds chats from the database's rows Every host install creates agent-session-journal.db, chats or not, and the version probe created it too, so its mere existence made every profile that ever installed the host wait on host install and reconcile at startup. The check now opens the database read-only and looks for a record or tab row, lets the records file answer while its import is still owed, and counts an unreadable database as present. The version probe no longer creates the file. * fix(native-chat): open a chat from history when its tab index cannot be written Over records a newer Orca wrote, every write is refused, so opening a closed chat from Agent Session History failed on the tab-visibility write and the chat read as unreachable. Like closing a tab, opening one now reports a failed restore-index write and still publishes the tab. * fix(native-chat): keep the records import owed when the backup read fails transiently A torn records file whose .bak could not be read (EACCES, EIO) was reported as unusable, so the migration completed with nothing copied and never retried. A non-ENOENT read failure of either copy now carries its cause, which the importer classifies as a read that can clear. * test(native-chat): pin that an unreadable records file never falls back to its backup * fix(native-chat): restore imported chats' tabs when the records file had no tab index A chat created while the import was owed recorded a tab index holding only itself. When the file it later imported had no index, that index still read as recorded, so the imported chats' tabs never came back. The import now clears the recorded marker in that case, and restore falls back to the profile's tabs. * refactor(native-chat): drop the unused in-transaction store write Nothing called it, and it bypassed the write queue and the read-only refusal. * docs(native-chat): say that an unusable records file is left untouched but never re-imported * refactor(native-chat): keep the provider handle chain check as main has it The chain-validation refactor has no measured need in this change. * docs(native-chat): retire lease-renewer comments that describe the records file as the live store * fix(native-chat): keep a throwing failure sink from failing the startup chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn. * test(native-chat): wait for a replaced host's restart-offer writes before cleanup A restart test replaces the host without tearing the old one down, so the old host's fire-and-forget restart-offer withdrawal could still hold the recovery capsule's lock directory when cleanup removed the test directory (ENOTEMPTY). The harness now hands hosts a capsule that tracks running operations and waits for them before removing the directory, replacing the rm retries. * docs(native-chat): retire the abandon helper's note that the store re-creates its directory * fix(native-chat): restore a chat opened while the import was owed beside the profile's chats When the imported records file had no tab index, restore fell back to the profile's saved tabs, which never list a Claude chat, and the seed then rewrote the tab table without the chat opened while the import was owed. The tab rows that chat left are now loaded as unrecorded, restore takes them together with the profile's chats, and the seed keeps their tab ids. * test(native-chat): pin that a create whose tab index write fails still opens the chat * docs(native-chat): say why restore puts chats opened while the import was owed first * test(native-chat): replace a ledger row rather than change it in place in the Send-now rerun test The record store freezes published rows in tests, so setting a row's outcome in place threw; the test now swaps in a changed copy, as its sibling cases do.
…s its row (stablyai#24225) * fix(relay): refuse a redial at once while the host's own release holds its row During an Asia drain the host whose socket closes is the one that redials. Its release on the draining cell locks its assignment row first, then waits on the cell's busy row for up to the lock timeout. The director's sticky and placement paths waited on that assignment row inside the single sticky slot, and the sticky path then locked the busy cell row itself before it checked isolation. The slot backed up and dials timed out fleet-wide. Both paths now take the host's assignment row NOWAIT and throw RelayAssignmentRowBusyError when it is held. /v1/assign answers that with 503, Retry-After 1 and error assignment_row_busy, logged with its own reason. The sticky path decides isolation before it touches the pinned cell row. An isolated retry keeps its own tier as its retry scope, so a busy lock inside it no longer falls to the all-rows path. The local drain arm drops its zero-release-failures bar, which the Asia arm never had. The drain harness gains a departing-host arm: each host releases its own lease, then redials after 150, 400 or 1000 ms on the desktop client's 5-5.5 s pacing. At 400 ms, main rejected 83 of 180 first dials by sticky wait timeout, placed 11.6/s with 3.1 director backends lock-waiting, and took 11.2 s at p95 from release to placed. Now: 16 fast refusals, 18/s, no lock waits, 5.7 s at p95. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010 * fix(relay): wait briefly for a calm host's row and keep the dead-cell sweep going The dead-cell sweep treated RelayAssignmentRowBusyError as fatal, so one busy host ended the sweep for every later host each tick. It now skips that host and carries on. The sticky path refused a busy row at once for every host. A calm host redialling after its own clean close often meets its own short release, and a refusal costs it the client's 5 s assign gate. When the pinned cell is general and live, the sticky path now waits up to 1 s for the row before refusing. A roll-isolated, parked or dead cell still gets the immediate refusal. Placement keeps NOWAIT, because it holds cell rows while it would wait. A resume refused for a busy row now carries Retry-After 1 as well. The departing-host harness arm now bounds the busy refusals at 20% of hosts and the p95 at 8 s, and counts unexpected errors apart from retryable refusals. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010 * fix(relay): never wait on a host's row while the sticky retry holds its cell row The inventory-first sticky retry takes the pinned cell row before the assignment row. With the calm-host bounded wait it could then wait up to 1 s on the assignment row while holding the cell row, the reverse of the ranked lock order, against this host's own release, which holds its row and wants the cell's. The bounded wait now applies only when no cell row is held; the retry stays NOWAIT. Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
…ven end Couldn't confirm (stablyai#23467) * refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * fix(native-chat): a request that failed reads as failed A structured chat whose only message the agent's start refused read as a green finish, and a cancelled structured turn did too: the host published a verdict only for turn records, and structured rows carried no `interrupted`. The host projection now reads the session's latest request: its turn's outcome, or `failure` for a send the agent or its start refused. A send that was withdrawn, or left undelivered by a restart or a close, fails nobody and makes nothing listable. The ingest publishes `interrupted` as the hook lanes do, and every reader decodes the verdict through one accessor, so a failure reads Failed on the dot, the rollups, history and `worktree ps`, behaves like a cancellation in every clean-finish policy, and notifies as "failed". * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): a verdict change republishes the mobile status projection * refactor(native-chat): the store's retention trigger keeps its flag compare A verdict change always moves the completion clock the same check already reads, so a second verdict compare there caught nothing new. * test(native-chat): a user message the provider journaled keeps its session listed * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * fix(native-chat): a late provider-session update keeps a failed recovery record failed A provider-session heartbeat that rewrites a completed recovery record kept its interrupted flag but dropped the outcome it was copied with, so a live failed checkpoint read as a clean finish until the next status write. * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * test(native-chat): the terminal-bell check asserts the renamed verdict field The bell notification test still checked for agentInterrupted, which no longer exists, so it could not catch a verdict leaking into a bell dispatch. * fix(native-chat): a failed turn ranks like a completion for attention Attention readers (completion time, Smart Sort, sticky retention, Cmd+J Recent) now demote only a turn the user stopped. A failure is news the user has not seen, so it keeps its completion time, ranks in the Done class, stays retained after its pane goes away, and a retained failure reads failed in the worktree rollup instead of done. Clean-finish policy (hibernation, pane ownership, the value moment) still treats a failure like a stop. The retention trigger compares verdicts again: success -> failure no longer moves the completion clock. * fix(native-chat): a failed main agent reads failed while its subagents still work The verdict is now read from the main agent's own state, not the folded row: a main agent that is done and failed has a verdict even while its subagents keep the row working. Without mainAgent (history, worktree ps, older hosts) the old combined-done rule stands. Display marks the verdict through agentVerdictDisplayMark: a failure outranks every combined state on the agent's dot, label, tab badge, dashboard and activity rows; a stop marks only a done row, so a successful or stopped main agent with live subagents still reads working. Subagent rows keep their own state. The worktree card, terminal tab and Cmd+J rollups share one pane fold and rank a pending question, then failed, then working, monitoring, interrupted and done. worktree ps publishes the main agent's outcome on a working row, and the mobile mirror reads it. The store's change check, the paired-client mirror's equality and its epoch now see a verdict change on a working row, which otherwise moves no state or clock and left the worktree card reading working. Clean-finish policy is unchanged: a working row is never hibernated and has no completion time. * docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it The field is new: an old host sends no outcome at all, so a reader falls back to interrupted. The removed clause said old hosts send it on done rows, which never shipped. * docs(native-chat): the status-store listing rule names provider-journaled user messages * fix(native-chat): a refused send notifies failed through the completion feed The host's completion feed followed only the newest turn, so a send the agent or its start refused, which creates no turn, read Failed on its row but sent no notification. The feed now follows the session's latest request, read from the projection the status feed already makes for the commit: a turn keeps its id, a refused send is named by its journal item key. It announces only while the session is idle, as the row reports a verdict, so queued sends refused one commit at a time notify once, and a withdrawn send falls back to a request already announced. * fix(native-chat): every copy of a row carries the main agent's own status History entries, sleep records and `worktree ps` rows carried a flattened top-level `outcome`, copied under different gates and without the main agent's clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type the live row already persists and sends, and every copy site takes it with `interrupted` through one function, `agentVerdictFields`. - The accessor reads `mainAgent` then the legacy flag; the mobile mirror matches it line for line. - Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a malformed value drops the field, never the record. - Mobile dates a main agent that failed under live subagents by its own clock, as desktop does, and its row equality compares `mainAgent`. - The activity feed reads a history entry's own `mainAgent` instead of rebuilding one; the sync key and history equality compare it. * test(native-chat): pin the worktree ps verdict across host and phone versions Pairs the real v1.4.212 host and phone row reader with this build: an old phone reads a new host's rows by `interrupted`, a new phone reads an old host's rows (no `mainAgent`) the same way, and a new phone reads a failure under live subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout now carries the phone's self-contained row reader, and the lane runs when the `worktree ps` row producers change. * test(mobile): name the parity table's row for its role * fix(native-chat): a request that settles while the user is asked something notifies once The completion edge waited for an idle session, and a pending prompt (including a subagent's approval) is not idle. Structured chat has no other attention producer, so a main turn that finished while a subagent waited on the user sent nothing until the prompt was answered. The edge now waits only on owed work (a running turn or an unanswered send), which the projection reports even beneath a pending prompt. A request that settles with a prompt pending announces once; the renderer words it "needs input" from the host status mirror's `attention`, and answering the prompt keeps the same request identity, so it does not announce again. The wire shape is unchanged. * fix(native-chat): the completion says when the user is being asked A request that settles while a prompt waits on the user was worded "needs input" from the renderer's status-feed mirror. Remote clients receive the status and completion streams over separate sockets, so they can arrive in either order and the wording could be wrong both ways. The host already knows at emit time, so the completion now carries an optional `awaitingUser: true` in that case and omits it otherwise. The renderer words the notification from that field alone and no longer reads the status mirror. Old clients ignore the field and word by outcome; old hosts never send it. * fix(worktree-status): a departed agent's failure yields to live work on the worktree card A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome. * docs(agent-status): a departed agent's failure ranks below live work on the worktree card * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(cross-version): load the phone row readers without mobile's toolchain Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json extends expo/tsconfig.base.json, which the root-only cross-version lane never installs. The worktree ps verdict suite imported the current phone row reader from mobile/ directly, so CI failed with TSConfckParseError before any test ran. The harness now imports a copy of the working-tree reader placed under the checkout cache, where the root tsconfig applies, as it already does for the release checkout's copy. Both readers are still the real files. * test(cross-version): keep the checkout path-guard message and justify the copy import's cast * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm When the provider gave no verdict, the structured status projection now derives one from the newest turn's lifecycle: interrupted -> interruption, unverifiable -> unconfirmed. Nothing new is journaled, the completion feed stays provider-only, and the legacy interrupted flag stays a user stop only. Every verdict reader handles both arms explicitly. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * fix(native-chat): a folded turn a crash cut off reads Interrupted after N The settled-turn timing now carries the turn's verdict, derived by the same agentTurnVerdict the status row uses. A turn that ended interrupted with no provider verdict heads its fold 'Interrupted after N'; a user's stop keeps 'Worked for N'. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * fix(native-chat): the user's close of a chat records the turn it cuts short as their cancellation The expected-close settle writes outcome cancellation when the user aimed the stop at this chat: a Stop while the agent starts, the chat's tab closed (the agentSession.close RPC, or session.tabs.close with a user reason), or /clear. A quit, an idle eviction, a worktree teardown or an orchestration stop leaves the turn with no verdict, so it still reads Interrupted. * test(native-chat): a Claude turn a newer send superseded reads Interrupted The supersede fires for any send Orca dispatched, the user's or another agent's, and nothing at that site records the sender, so the turn keeps no verdict and folds as Interrupted after N. * fix(native-chat): the user's close records cancellation on the turn the provider settled on its way out The Codex and Claude adapters settle their open turn as interrupted, with no verdict, while the host stops them. The expected-close settle then found no running turn, so a user's close of a mid-turn chat read Interrupted. The stop now reads the running turns before it reaches the provider and records the user's cancellation on each one it cut short, unless the provider gave a verdict of its own. The close-verdict test's adapter now settles its turn on close the way the real adapters do. * test(native-chat): update the close and settled-turn expectations for the host-observed verdict agentSession.close now passes the user's word to the host, and a settled interrupted turn with no provider verdict carries `interruption`. Also merge a duplicate import the code-quality gate rejects. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * fix(native-chat): the user's close cancels a turn whose start landed as the provider stopped The close read which turns were running before the stop. A turn whose start was still in flight (a send echo not yet journaled) was absent from that read, so the provider's verdict-less settle on the way out left it Interrupted. The close now reads which turns were already over instead, and records the user's cancellation on every other turn the stop left running or interrupted with no verdict. A turn cut off earlier, or finished during the stop, keeps its end. * fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send that is absent from a trustworthy provider history with reason 'not_delivered'. Nobody failed that send, but the verdict allowlist did not name it, so after a crash the chat read Failed, was listed, and could notify "failed". Give the reason a shared constant (persisted value unchanged), add it to the no-verdict set, and treat it as an internal marker so the Retry row no longer shows the raw string. * refactor(native-chat): the adapter settles the turn a stop cuts with the stop's typed cause The host hands its stop's cause to the adapter's close. Each adapter settles its own open turn on 'ended' through one mapping, turnVerdictForChildEnd, and the host's dead-generation fallback uses the same mapping for any turn no adapter settled. A user's close or stop of this chat is their cancellation; a quit, eviction, teardown or an exit the adapter saw first is news. Deletes the snapshot-and-diff reconstruction (endedTurnItemIds, userStoppedTurnRevisions, settleUserStoppedTurns) and the requestedByUser flag. host.close now takes a required cause. * test(native-chat): a user's close drops the chat's status row like an eviction * test(native-chat): expect the eviction cause on the host closes of idle release, worker stop and worker discard The stop's typed cause now travels into host.close and the adapter's close, so these three non-user closes assert the 'evict' they pass. * refactor(native-chat): every stop names its cause, so none defaults to the user's cancellation stopStructuredAgentSessionAgentUnderSerialize defaulted its ending to 'user-stop', which now settles the cut turn as the user's cancellation. Every caller already passes a cause; the parameter is now required, and a type-level test fails to compile if the default returns. * test(native-chat): pin who a chat's session.tabs.close speaks for, older clients' reasonless close included The mapping lived inline in a type-unchecked file, and only the explicit user reason had a test: an older client's reasonless close, or a lifecycle echo read as the user's, stayed green. It is now one exhaustive, type-checked function with a case per reason. * style(mobile): draw the unconfirmed dot in the theme's status amber, not an inline hex * docs(agent-status): the main agent's outcome also carries the host-observed end, interruption or unconfirmed * fix(native-chat): a chat the user closed while its agent started is not a failed start A still-starting child the user's close cut counted as a failed start, since only 'user-stop' was excluded: a start-failure row, and queued messages rejected as a provider failure. Whether an ending fails its start is now one exhaustive switch, shared by the failed-start read and the delivery loop's handover: a user's stop or close never does; an exit, a failed attach and the host's own stops still do. * fix(native-chat): a chat the user closed closes its queued messages, and starts no agent for them After 876b6989f1 a user's close of a still-starting chat went on like a Stop, so when the close did not complete the delivery loop started a new agent for the message queued behind it. A child's end now has three dispositions, not a failed-start boolean: a user's Stop lets the queue go on, the user's close closes what was queued before it, and any other end fails it. The close is the one a completed close does (the provider-closed rejection, no verdict), applied at the top of each delivery step and ordered against the close so a later send still goes on. * fix(activity): a crash-cut turn draws the interrupted glyph; only a user's Stop keeps the done check The Activity page drew every Interrupted row with the done check, which #2569 chose for a user's Stop. With a crash now reading Interrupted, that put a green check on a turn nobody asked to stop. The row's glyph is now an exhaustive switch over the verdict: a cancellation keeps the done check, and an interruption draws the existing interrupted dot. An unconfirmed end already drew its own glyph. * fix(activity): the Interrupted group header draws the done check only when every row is a user's Stop A user's Stop and a crash share the Interrupted group, and its header took its newest row's glyph, so a Stop newer than a crash put a green check over the crash. The header is now folded over the group's rows: the done check only when every row draws it, the interrupted dot otherwise. * fix(native-chat): a failed close of what the user closed starts no agent for it Rejecting the messages a user's close left queued swallowed a journal write failure, so the delivery step went on to start an agent for a message in a chat the user closed. The rejection now reports whether it landed, and a step whose rejection failed stops instead; the next wake re-derives and retries it. Also pins that the ordering against the close holds only within its epoch, since a later epoch's sequences restart. * test(native-chat): the idle sweep's stop is an eviction, so its close carries that cause Main's idle sweep now stops an idle agent through the conversation lifetime, which this branch gives the 'evict' cause; its expectations name it. * fix(native-chat): a retried stop keeps the cause of the stop it finishes A user's Stop or close whose wind-down failed after the child was proven gone was finished by the idle sweep as an eviction, so the turn it cut read Interrupted. The owed wind-down now carries its stop's cause, and a retry with no child settles with it. * test(native-chat): the idle sweep's close of a retrying Claude chat carries the eviction cause Main's new test expected the adapter close with the session id alone; every stop now names its cause, and the idle sweep's is 'evict'. * fix(status): a user's Stop marks done on the tab and sidebar; red Interrupted is only a turn cut short by something else The tab, the worktree card and the sidebar rows drew a Stop with the same red dot as a crash. The verdict mark now maps a cancellation to done, still saying "Interrupted by user" in the row text, and the mobile mirror follows. The Activity page keeps grouping a Stop under Interrupted with the done check, as before. * test(cross-version): a new phone reads a user's Stop as done; an old phone still draws it interrupted * fix(native-chat): a user's Stop inside a live Claude chat reads as their cancellation Stopping a running Claude turn interrupts it and keeps the session, so the turn's end comes from the CLI's result frame. Claude CLIs before 2.1.91 send that frame with no terminal_reason, and later ones may still omit it, so the user's own Stop was recorded as a failure with an error row. Orca now records the stop on the open turn when it sends the interrupt. An error result for that turn reads as the user's cancellation whatever reason the CLI gives. The stop belongs to that one turn, so it cannot reach the next, and it is withdrawn when the CLI refuses the interrupt. * docs(agent-status): a user's stop marks done; name the tab close cause by its type The reference still said a stop marks a row interrupted and ranks between live work and an unconfirmed end. A cancellation now marks done, and only a turn cut short by something else ranks as interrupted. The runtime's tab close restated the close cause's union; it now uses the type. * test(native-chat): a proven crash reads as an interruption on the status feed and in the chat A crash the relaunch proves now settles its turn interrupted, and the status feed works the verdict out from that record, so the restart test expects interruption for a proven crash and unconfirmed for one it cannot prove, never a cancellation. A chat read before the proof lands reports unconfirmed, then interruption and a folded "Interrupted after 27s" once the proof revises it. * fix(status): a user's Stop reads Interrupted, and a turn anything else cut short reads Failed The verdict mark now maps a cancellation, the user's own Stop, to interrupted, and an interruption, a turn cut short by a crash or a killed agent, to failed, the same as a failure, which outranks live subagent work. An unconfirmed end is unchanged. This applies to every agent, in a terminal or a chat, on the tab, the sidebar rows and worktree card, the dashboard row, Cmd+J and the phone. A Stop is not news, so the rollups rank it below an unconfirmed end, and notifications word an interruption "failed". Recording is unchanged. * fix(activity): group a user's Stop under Interrupted and a crash with failures A user's Stop draws the interrupted glyph and sits alone in Interrupted, and a turn anything else cut short sits in Failed, titled "Agent failed". Every row in a status group now draws the group's own glyph, so the header is the group's status and the rule that folded a Stop's done check into the header is gone. Interrupted ranks below an unconfirmed end, as in the sidebar. * fix(status): draw a user's Stop in the muted tone, not the fault red The interrupted dot, which now means only a user's Stop, draws in the muted foreground token on the agent rows, the sidebar card and the phone. Red stays for a failure or a turn cut short by anything else, and green for a finish. * fix(native-chat): fold a stopped turn as "Interrupted after N" and a failed or crash-cut one as "Failed after N" The settled turn header now follows the verdict mark: a user's Stop reads "Interrupted after N", and a failure or a turn anything else cut short reads "Failed after N", under the new key components.native-chat.status.failedAfter in all six catalogs and the boot catalog. Desktop and phone share the one description, so they agree. * docs(agent-status): describe the Interrupted and Failed marks The reference and the phone's turn bar still described a user's stop as done and a crash as interrupted. A fault now reads failed, a user's stop reads interrupted in the muted tone, and the rollups rank an unconfirmed end above a stop. * test(status): a crash the relaunch recovers marks failed The recovery test still expected a recovered interruption to mark interrupted; it now marks failed, as a failure does. Formatting only elsewhere. * fix(native-chat): record a turn a newer request replaced as superseded, and show it Interrupted A Claude turn that a newer send replaced before its result arrived was recorded as interrupted with no verdict, which reads as a turn cut short by something else, now "Failed". It is now recorded with its own outcome, `superseded`, where the replacement is detected. That outcome names no sender, so a dispatch from another agent is never recorded as the user's Stop, and it sets no legacy flag. Every reader handles it in an exhaustive switch: it draws the muted Interrupted mark with the plain text "Interrupted", folds as "Interrupted after N", and attention demotes it with a Stop, through the renamed agentTurnEndedOnRequest. Older builds read an arm they do not know as no verdict, which is what this turn carried before, so their rows keep reading done; the cross-version suites pin an older desktop's journal and status readers and an older phone. * refactor(status): name the attention predicate for a turn ended on purpose agentTurnEndedOnRequest becomes agentTurnEndedOnPurpose: a user's Stop or a newer request's replacement, never a fault. The Claude turn-end comment no longer says a replaced turn carries no verdict. * test(native-chat): a turn cut off by a restart or by quitting Orca reads Failed after N On main a restart-cut turn shows the done tick. Pin the chat's turn bar and the tab's mark for both cuts, through the recovery settlement and the quit's child-end mapping, and pin the quit's turn bar through the host's own quit. * style(native-chat): format the superseded turn-bar expectations * fix(claude): a Stop that names no turn is the user's stop of the open turn The chat's Stop button names no turn. Claude's conversation Stop recorded the user's stop only for a named turn, so an older CLI's error result after that Stop read Failed. It now records it on the open turn through the same intent, dropped when Claude refuses the interrupt and never carried to the next turn. * fix(native-chat): keep the attach context's publishStatus required The lifetime context type makes publishStatus optional, so the attach context that spreads it no longer satisfied its own type once its duplicate publishStatus went. The host's lifetime context is now inferred, and checked with satisfies, so the spread carries the member it always sets. * fix(activity): rank a user's Stop below live work in the status grouping The Activity page's status grouping put the Interrupted group (a user's Stop, or a turn a newer request replaced) above Working and Monitoring, so a Stop still sorted like news there while the sidebar, worktree card and Cmd+J rank it below live work. It now follows live work and stays above Done; Failed and Couldn't confirm keep their places above live work. * docs(agent-status): say which turn outcomes the journal records and which are derived The journal now records superseded as well as the provider's verdict and a stop; interruption and unconfirmed are derived on read. The resume row no longer claims interrupted renders red. * test(native-chat): the idle sweep's held-send rest closes with the evict cause, like its siblings --------- Co-authored-by: Claude <noreply@anthropic.com>
…s in place (stablyai#23684) * fix(native-chat): a provider's own retry progress is quoted in its retry row A retry row whose fact carries a detail the provider wrote for a person now quotes it, the same way a rejected message or failed compaction does, so the row says how the retry is going. A log detail still stays out of the sentence. * fix(native-chat): a Codex stream retry is one warning row that updates in place An error Codex says it will retry used to fall through to the generic frame row: red, and a new row for every attempt. It now writes one providerRetrying row per retry run, warning-toned, revised by each attempt with Codex's own progress sentence. A run is the retry frames of one turn with nothing else the thread journals between them; every attempt still publishes, so the idle sweep keeps seeing activity. Errors Codex will not retry are unchanged. * fix(native-chat): a Codex retry row says it is retrying and keeps the frame behind Details The quoted retry sentence leads with "is retrying", which holds for any provider's progress text. The Codex retry row also keeps the whole bounded frame behind the row's Details, as the generic row did, so Codex's additionalDetails stays available to diagnose a retry. * test(native-chat): a Codex retry re-handled after backpressure keeps its one row Pins the run being opened before the write: a first attempt whose publish is refused and is handed back must revise the row it already wrote, not open a second run. Also stops the fixture claiming Codex sends an idle thread status beside each retry, which the app server does not do. * perf(native-chat): a Codex frame with no retry run open is not classified Ending a retry run classified every non-retry frame, and classifying walks the whole payload: every streaming delta, and every large item/completed, paid a walk about as costly as parsing the frame. Only a thread with a run open needs the answer, so the classification now runs only there. * fix(native-chat): each Codex retry attempt is its own warning row, with what failed on its second line A stream error Codex says it will retry is written as its own warning row with a providerRetrying fact, under the same per-frame identity every Codex frame row gets. The host no longer tracks retry runs or rewrites one row in place, so there is no run state to open, end or clear, and no frame has to be classified to end a run. Every attempt publishes, which keeps renewing the idle clock while Codex retries. Codex's additionalDetails, which its own UI shows under the progress message, is kept on the fact as the retry's cause and printed on the row's second line. Errors Codex will not retry are unchanged. * fix(native-chat): a transcript draws only the latest row of a provider retry run The shared structured message projection, which both the desktop and the mobile transcript read, collapses a run of retry rows into its latest row. A run is retry rows from the same agent with no other drawn row between them; a row that draws nothing, or a queued send drawn after the conversation, does not split it. The earlier attempts stay in the journal. * test(native-chat): the retry-run render test uses the message list's current props * fix(native-chat): a Codex frame row is named for its connection, so a later one never revises it Frame rows were named provider-frame:codex:<n> from a counter that starts over with every connection, so the first frame row after a reconnect in the same session revised an earlier connection's row in place, at its old spot. Each connection's frame rows now carry the acquisition generation, minted once before the translator is built: provider-frame:codex:<generation>:<n>. Rows already written keep their identities. * fix(native-chat): agents retrying at once each keep one row, read from the row's own agent * test(native-chat): a reconnect's Codex rows are named for the acquisition that received them * fix(native-chat): a retry run is one agent's, so another agent's row never splits it Each agent's rows are drawn apart: the session's own rows are the conversation, and a subagent's rows open in that subagent's section. Splitting a run on any other row in the flat list left two adjacent retry rows on screen whenever another agent wrote between two attempts: a subagent finishing a command while the session reconnected, or the session working while a subagent reconnected. A run is now per agent: an agent's retry rows with none of its own other rows between them, drawn as its latest. * test(native-chat): a subagent's retry run is checked in its own section, and the run rule's words say same agent * test(mobile): the retry-rows test typechecks, so the test ratchet keeps checking it
…ce you switched to (stablyai#23974) * fix(worktrees): a finished create no longer pulls you off the workspace you switched to Show a ready toast with an Open action instead (stablyai#9944). * fix(worktrees): a local agent create no longer opens its workspace from the host For a composer create on a local repo that starts a terminal agent, main spawned the agent terminal with `activate: true`. The renderer turned that into a workspace switch before `createWorktree` resolved, which cleared the creation panel pointer: a user who had moved to another workspace was still pulled onto the new one, and every such create also showed a redundant "ready" toast and lost its completion focus. The host now adopts the startup and setup terminals silently (`surfaceOwner: false`, no activation), the same way runtime-managed creates do when not asked to activate. The submitting renderer's completion rule is the only thing that decides whether to open the new workspace. Also: the ready toast's Open is a deliberate user open (`navigationIntent: 'user-open'`), and the renderer tests now drive the composer's live entry and reach the post-create cancel check. * test(worktrees): pin split-mode setup silence and agent-tab focus on completion - Pin that split-mode setup is split into the startup terminal without surfacing the new workspace. - The watching-user completion test is now a real agent create: activation returns no primary tab, as it does in the app. - New store-backed test drives the host's silent reveals through the real bridge, then the completion's activation and focus queue, and checks focus lands on the host-adopted agent tab rather than the setup tab. * fix(worktrees): start a backgrounded create's renderer-owned startup without opening it When the host does not start the agent (SSH repos, local repos with project default tabs, VM creates) and the user has moved on, completion seeds the agent/setup/issue terminals without activating them. Nothing mounted those panes, so the agent, draft prompt and setup script waited until the user opened the workspace while the toast already said it was ready. The non-activating branch now asks for a background mount of just the created tabs that still owe startup work, the same request the terminal bridges and sleeping-agent wake already use. Idle tabs and host-started terminals are left unmounted. * test(worktrees): pin background mount for setup-split and issue-split only tabs * fix(worktrees): name the ready toast by kind and skip it for a user already there - The toast now reads "Worktree <name> is ready" ("Workspace <name> is ready" for folder repos, which are not git worktrees) with a "Go to worktree" action led by the external-link icon. Old readyToast/openReady keys are replaced in all six catalogs. - The created row is listed before completion, so a user can open the new workspace while it is still being created. Completion now treats that user like one still watching the creation panel (open + focus the agent, no toast), and the toast is also skipped if they opened it while a native chat was starting. * fix(worktrees): drop the unreachable toast-time "already on it" re-check Nothing between the completion decision and the toast yields to user input (the structured-chat launch awaits nothing), so the re-check could never fire, and a future await would have let a user who reopened the panel get neither activation nor a toast. The decision-time check stays. Also clarify why a folder workspace's title says Workspace while the button keeps one label. * fix(worktrees): folder workspace ready toast says Go to workspace
…blyai#23722) * fix(opencode): report OpenCode 2 status from each pane's TUI OpenCode 2 serves every pane from one shared server whose env names only the pane that started it, so every same-folder pane's work showed on that pane, and the plugin's single aggregate swallowed the Idle of a pane whose turn ended while another pane was busy. Install the status plugin a second time as an OpenCode 2 TUI plugin (plugins/<name>-tui/tui.js, written only when its bytes differ, locally, in overlays and in the SSH/WSL relay installs). In a TUI process setup() runs the same engine, fed through the same event translation as the server, but only for root sessions this pane owns: the route's session from when it starts (or when the route reaches it while running, hydrated from the TUI's session status) until it settles, is deleted, or the TUI exits. The server plugin stands down in any serve process when the TUI copy is installed beside it; a relay that predates the TUI copy keeps the old behavior. OpenCode 1 loads no plugin directories and is unchanged. Delete the session-to-pane binder, registry, client sweep and ingest reattribution: with every post stamped by its own pane nothing is left for them to correct. Known gap: OpenCode 2 `opencode run` in a pane has no TUI, so it reports no pane status. * fix(opencode): settle missed OpenCode 2 turn ends and order TUI installs - The TUI copy now settles an owned root whose run the TUI's session data reports ended, with no pending permission or form, when the engine still holds it busy. An execution end missed across a service restart or reconnect no longer leaves the pane Working until the TUI exits. - The TUI copy stays idle without ORCA_PANE_KEY. post() cannot report without it, so OpenCode 2 TUIs outside Orca no longer run the route poll and engine. - Installers write the TUI copy before the server plugin file. The server decides at load whether to stand down, so a reload between the two writes now finds the TUI copy. * fix(opencode): reconcile OpenCode 2 TUI status only once queued events drain The TUI's session data applies each event before this plugin's queued handling reaches it, so settling against it mid-backlog published a false Done before a fast turn's later steps (Working, Done, Working, Done). Reconcile only when no event is queued; a mismatch then is a start or end missed across a reconnect, and both directions are now re-derived (a missed start left the pane on Done). Also install the TUI copy beside the server plugin in the retired shared hooks dir, so a TUI or service still loading that dir reports per pane instead of leaving the service reporting under its starter pane. * fix(opencode): keep OpenCode 2 TUI panes silent on plugin dispose A TUI plugin hot reload disposes the plugin while the pane's turn keeps running. The server path already passes sessionsOutliveDispose so dispose publishes nothing; the TUI adapter now does the same, or every TUI reload would still show a false Done. Also refreshes the generated-bytes digest after rebasing onto the write-if-changed installers. * fix(opencode): refresh installed OpenCode status plugins at app start After an Orca upgrade, an OpenCode 2 service that was already running kept the previous plugin, and with it the old wrong-pane status, until any new terminal pane rewrote the file. OpenCode 2 reloads a plugin whose file changes, so Orca now refreshes its existing installs once after the first window shows: the global config dir, source overlays and the retired shared dir, TUI copy first. It reuses the per-pane writers, which skip unchanged files, never creates an install the user did not have, and honours the status-hook and per-agent switches. An SSH relay does the same for its canonical install when Orca connects and ships the plugin sources. The plugin source assembly moves to its own module (re-exported unchanged) to keep hook-service.ts under the line limit. * fix(opencode): derive each OpenCode 2 pane's status from its TUI session data The TUI copy of the status plugin translated OpenCode events into the server-side engine and then patched the engine's latches back toward the TUI's own session data: a settle on every tick, a queued-event counter, a re-assert, synthetic Busy and Idle. Each review found another place where the two copies disagreed: a missed end left the pane Working, a backlog of slow posts flickered Done, a missed start showed Done mid-turn, a turn held only by a subagent never settled, and a request answered while disconnected pinned Needs input. The TUI reporter now reads the pane's level straight from the session data OpenCode keeps current (and re-hydrates on reconnect) on every event and on a 100 ms tick: for each root session this pane owns, Needs input (an open permission, else form, anywhere in its family while it runs) outranks Working (any family member running) outranks Done. It posts one status per level change through the plugin's existing delivery functions (retry, dedupe, message-part throttle, ordering), so the wire and Orca's ingest are unchanged. Ownership is kept in OpenCode's storage.memory, which survives a plugin hot reload, so a turn that ends during a reload still shows Done; dispose publishes nothing, and a level the old generation could not deliver is re-posted by the next. On reconnect it re-syncs blockers for the owned sessions OpenCode would not re-sync itself, ignoring requests already answered. Events are handled synchronously, so a slow post can no longer hold up event processing. The server path, OpenCode 1 and mimo are unchanged. * fix(opencode): keep an OpenCode 2 pane on Needs input while its root streams text Orca treats every OpenCode MessagePart as Working. While a background subagent waits on a permission or form, the root session can keep streaming reply text, and the TUI reporter forwarded that text as a MessagePart. The pane then flipped from Needs input to Working, and nothing restored it until the level changed, so the user could miss the open request. Skip reply text while the pane's level is Needs input, as the reporter already does for queued prompts. * fix(opencode): leave OpenCode 2 step events to the server path's own change The shared-server translation re-derived Working from session.step.started. The pane reporter no longer uses it, so it only changed the server path and duplicated a separate open change. Drop it; server behaviour matches main. * fix(opencode): reset an OpenCode 2 pane to idle when its TUI starts Before this change, a pane could keep a status an earlier process left on it. The case that matters is an upgrade mid-turn: the old shared-service plugin posted pane B's turn under pane A's key, then stood down, and pane A's TUI loaded the new reporter owning nothing, so it never posted and A showed a wrong Working until its own next turn. A freshly started reporter that owns no running turn now posts the host's existing session-start boundary once, which the host shows as connected idle: no completion, no notification, and an unseen Done it lands on stays unread. A plugin hot reload keeps its memory and skips it, and where OpenCode keeps no plugin memory it is never sent. * fix(opencode): keep OpenCode 1 serve + attach sessions on the attaching pane OpenCode 1 `opencode serve` in one pane plus `opencode attach` in others reports every session from the serve process, whose environment names only the serve pane. Before this branch the session-to-pane binder moved those posts to the attaching pane; deleting it for OpenCode 2 put them back on the serve pane. Restore the binder, registry, correlation and client sweep for OpenCode 1 (and mimo-code, which it also served), gated so OpenCode 2 never uses it: - The status plugin now sends `opencodeMajor: 2` on every post from a process whose loader called setup(), which only OpenCode 2 does. The host skips the binder (no rewrite, no kicked round) for any post that carries it. OpenCode 1 and older plugins send nothing and keep the previous behaviour. - The binder reads only OpenCode 1's `session` table. OpenCode 2 writes `session_v2`, so its sessions never bind; on a database both versions wrote, OpenCode 1 sessions are no longer hidden behind the v2 table. OpenCode-1-only; it goes when OpenCode 1 support is removed. * fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane OpenCode 2's `opencode run` loads no plugin, and the shared service that runs its session cannot tell which pane it belongs to, so a pane running it showed nothing. The runtime now reports it from the pane's own process lifetime. On the pane's OSC 133 command start (main already parses 133 for every local PTY; the start callback was never wired), after the pane tracker's 350 ms settle it reads the foreground process name through the existing foreground reader, and only when that name is OpenCode, the foreground command line from one fresh shell-foreground process-table capture. An `opencode run` posts Working through the existing terminal-status path into the hook server's store; the pane's 133;D (or the daemon's background fact) posts Done, marked interrupted on Ctrl-C. No polling. Exclusive with plugin reporting: each write carries the command's start time, and the store drops it once a hook has written the pane since then, so an OpenCode 1 `run` (in-process plugin) or a server plugin without the TUI copy owns its command alone and there is never a second Done. Local macOS and Linux panes only; SSH, WSL and Windows panes stay silent because their foreground cannot be read on this host. * fixup! fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane Type the selected descendants as process-table rows; ReturnType of the generic collector widened them to bare identity rows. * fix(opencode): keep an `opencode run` pane's Done after the command exits The run's Done was published inside the chunk that carried its OSC 133;D, before the chunk's command-finished fact. The renderer drops an exited agent's row on command-finished when the row has not changed since that fact arrived, so it took the Done as the stale row and dropped it, in the renderer and in main. Publish the run's Done after the chunk's side-effect facts are emitted (and after the daemon's background fact). The renderer then sees command-finished while the row is still Working, and the Done that follows counts as a change, so it stays, the same way a hook Done that lands after exit already does. * fix(opencode): let an `opencode run` revive a pane Orca retired When an agent Orca launched exits, command completion retires the pane, and only a hook new-turn event revived it. A later `opencode run` in that pane posts no hook, so its process-lifetime Working was refused and the pane stayed silent. A process-lifetime Working is posted only after a fresh OSC 133;C and the pane's own foreground argv prove a new OpenCode run, which is at least as strong as a hook new turn. The store now revives the retired pane on it the same way: it clears the retirement, drops the launch-token fence, and rebinds the observation. OSC status and a lone process Done still cannot write a retired pane, and a closed tab stays closed. * fix(opencode): report `opencode run` on local Windows panes too The run producer skipped every Windows pane, although Orca already resolves a Windows pane's foreground agent from the native process table. On main an OpenCode 2 `run` showed there (on the service's pane); on this branch it was silent. The argv read now has a Windows branch: one fresh native table read (windows-process-table, no interpreter spawn), the same foreground identity the Windows resolver already computes, and the command line of the process that identity names. It runs only after the name read says OpenCode. Local Windows panes whose shell prints OSC 133 C/D (PowerShell with PSReadLine, Git Bash with Orca's wrapper) now go Working, then Done; cmd.exe prints no markers and stays silent. SSH and WSL panes stay silent. * fix(opencode): re-read an `opencode run` foreground on the pane tracker's ladder The producer read the pane's foreground once, 350 ms after the command started. A wrapper, a shim or `sleep 1; opencode run` execs OpenCode later, so those runs stayed silent. The renderer's pane tracker already re-reads a command's foreground at 350, then 1200, then 6000 ms for exactly this. Move those delays to one shared module and use it from both. The producer re-reads only while the foreground is a non-shell process that is not OpenCode, and at most on those three rungs; no new polling. * fix(opencode): end an armed `opencode run` when the next command starts A new OSC 133;C in a pane whose `opencode run` was still armed dropped the armed state without a Done, so a run whose 133;D never arrived left the pane on Working. A new command start proves the previous command ended, so it now posts that run's Done (not marked interrupted: no exit code is known) before the new command is inspected. * fix(opencode): skip the session-start row inside an OpenCode 1 `run` process OpenCode 1 `run` loads the status plugin in its own process, and the plugin posts SessionStart when the run's session is created. The host lands that as an idle session boundary, so a pane the run producer had already shown as Working blinked idle before the plugin's Busy. The plugin now skips SessionStart when its own process is a `run`, read from its argv the same way isOpenCodeRunCommand reads a pane's foreground (first positional after global options, past the compiled binary's entry path). A `run` session goes Busy at once, and its first prompt part still revives a retired pane and resets the turn caches. The TUI and `serve` still post it, and OpenCode 2's `run` loads no plugin. * chore(opencode): say where the plugin's opencodeMajor comes from The field is set when OpenCode's loader calls setup(), which only OpenCode 2 does; the plugin never reads OpenCode's version. Say so at the field. Comment only; the generated plugin is unchanged. * test(opencode): type the late-Done pane fixture without bare casts The new renderer test passed its fixtures with `as never`; use the checked fixture-tuple cast with a SAFETY note, like the sibling pty-connection tests. * fix(opencode): keep `opencode run` silent when OpenCode status is turned off The run producer ignored the status-hooks switch, so a user who turned status off globally or for OpenCode (stablyai#23667) still got a run's Working and Done. It now checks isAgentStatusHooksEnabledForAgent for the run's agent (opencode or opencode2), the same predicate the plugin install honours, before reading argv or posting anything. * test(opencode): read the late-Done row without an untyped property access The mock store types agentStatusByPaneKey values as unknown, so reading `.state` failed tc:web. Assert the row with toMatchObject instead. * perf(opencode): read a command's foreground only while it could become `opencode run` Two costs the run producer paid on every command in every local pane: - The retry ladder re-read the foreground (a process-table capture on the local provider) for any non-shell program still running, including other agents, editors and dev servers, up to three times. It now re-reads only while the foreground is still the shell (the command has not exec'd yet) or an unrecognised launcher that may still exec OpenCode (node, bun, bunx, npx, npm, pnpm, pnpx, yarn). Any other program is read once. - With OpenCode status turned off for both opencode and opencode2, it still set the timer and read the foreground only to discard the result. It now returns before any timer or read. The per-agent check after the name read stays for the case where only one of them is off. * fix(opencode): let a new hook turn end a pane's process-exit completion A confirmed agent process exit records a pane-wide completion identity that names only the agent. Hook Dones are matched against it by agent alone, and only a working title cleared it, so in a pane whose agent paints no working title (OpenCode) every later hook-reported Done was treated as already notified: no notification and no unread mark. An unreported exit (a status-off `opencode run`, or quitting an idle client) was enough to set it. A fresh hook Working now clears a process-exit identity, since a new turn cannot be a duplicate of an earlier exit. Hook identities stay, so same-turn and replay dedupe is unchanged. * refactor(opencode): share the foreground read schedule as one value The pane tracker's import of the two shared foreground-read delays took four lines where its old local constants took three, which put the file one line over max-lines once merged with main. Export the settle delay and retry ladder as one object so each consumer imports a single name. No behaviour change: same 350 ms settle and 1200/6000 ms retries.
…ges crossed (stablyai#24231) * test: two tests catch up with the journal-database store and the close cause stablyai#24006 moved the session store into the chat journal database, and stablyai#23467 made close take a cause; stablyai#23684's and an older tab-table test still used the old calls, so main's typecheck fails. * test: the startup-reconcile close test passes the close cause too
…wait behind them (stablyai#23837) * fix(worktrees): delete removed checkouts in git, not in Orca's file pool Local worktree removal renamed the checkout into a sibling trash root and deleted it in the background with a recursive fs.rm in the main process. That queued one request per entry on libuv's shared 4-thread file pool, so for minutes every other async fs call in the main process (the agent-session store behind chat sends, file explorer reads) waited behind the delete. `git worktree remove` now deletes the checkout inline in git's own process again, so the card stays in its Deleting state for the length of the delete while Orca's file pool stays free. No timeout applies to the call, so a large delete is never killed halfway. If git reports success but the path still exists (Git for Windows leaves junctions and their parent directories in place), the leftover is deleted with the existing removeHostTree; WSL checkouts stay with the distro. Nothing creates trash any more: the scheduling queue, rename/restore helpers and the trash_rename span are gone. The startup sweep stays to drain entries older releases left behind, and now removes each emptied trash root so the obligation ends. * fix(worktrees): let Git delete Windows checkouts with long paths enabled Removal now always runs Git's own recursive delete, and worktree creation checks out with core.longpaths on Windows, so a deep checkout Orca created could fail to delete with "Filename too long" (stablyai#6433). The Windows recovery then finishes the delete but keeps the branch. Pass the same command-scoped core.longpaths option to `git worktree remove` so Git can delete what it created. Also point the CI shard timing entry at the renamed real-git removal suite. * fix(worktrees): keep an inherited GIT_ASK_YESNO out of the worktree delete Git for Windows asks $GIT_ASK_YESNO whether to retry when a file stays locked during a recursive delete. Orca's git env inherits the user's environment, so an inherited value would run an arbitrary prompt program in the middle of a removal. Drop it for the removal call only. * perf(worktrees): run worktree deletes under their own limit, outside git admission `git worktree remove` now deletes the whole checkout in Git's own process, which takes 20-35 s on a large tree. It took a general git admission slot at status tier for that whole time, and that cap is as small as two slots on a machine with six or fewer cores, so two deletes blocked every status read. Deletes now skip general admission and queue under their own limit of two per host instead: two concurrent deletes already saturate one disk, and more only slow each other down. Leftover cleanup runs inside the same slot. * fix(worktrees): delete removed checkouts in the background and mark them removing Since the checkout is deleted by `git worktree remove` in Git's own process, a large delete takes 20-35 s. Answering the request only after that made web and mobile (30 s), paired desktop (60/180 s) and the CLI (60 s) report a failure for a delete that was still going, and mobile silently re-showed the row. The request now does everything that can refuse (lock, cleanliness, archive hook, watcher/terminal gate, terminal stop, shared-link unlink), records the removal in an in-memory table on the host and answers `removing: true`. The delete, branch cleanup and metadata purge run after it in the same order as before, and the watcher/terminal gate stays held until they finish. - Listings mark rows in the table `removing` for clients that advertise `worktree.background-removal.v1` (the desktop renderer, paired desktop and web), and leave them out for everyone else (older clients, mobile, the CLI), which already dropped the row when the request answered. - The outcome (removed, with any preserved branch, or the error) rides the existing worktrees-changed event as an optional field, sent after the row has left the table. - A repeat delete while Git runs joins it. A create at the same path or with the same branch is refused with "Cleanup is pending; try again shortly"; create's name search skips the path, so generated names move on. - Nothing is persisted: after a quit or crash Git still lists the checkout and it can be deleted again. WSL checkouts still delete inline. - `orca worktree rm` says the checkout is still being deleted. * fix(worktrees): keep the existing Deleting card until the host's Git finishes The host now answers a local worktree delete on acceptance and deletes in the background. The renderer keeps the existing delete state set until the host publishes how it ended: - The delete that asked waits for the outcome on the worktrees-changed event (local IPC or the paired runtime's client event), then runs the same teardown, preserved-branch toast and card error an inline delete did. If that event is lost to a dropped connection, a listing that shows the row gone after it was marked removing finishes the wait, and one that shows it back without the marker fails it. - Any other renderer (a reload, a paired desktop, web) sets the same delete state from the host's `removing` marker and clears it when the marker goes. A failure the host publishes lands on that card's existing error. - Web advertises `worktree.background-removal.v1` so the host sends it the marker; paired desktop does through the Electron capability list. No new component, style or state: the card reads the delete state it always did. A host that predates this answers when done without `removing`, and the renderer takes that as finished, as before. * test(worktrees): type the removal harness and projection for the node typecheck * fix(worktrees): don't fail a delete retry with an earlier attempt's buffered failure A background removal's outcome that reached this renderer with no waiter (another client's delete, a host-marked card, or one already settled from listings) was buffered for 60 s and consumed by the next delete of the same workspace, so retrying a failed delete failed at once with the old error while the host was deleting. Drop the buffered outcome before sending the request; only an outcome that arrives after it can belong to it. * fix(worktrees): let only a gap in host events settle a background delete from listings Git unlists the checkout before the host deletes the branch, cleans the push target and purges metadata, and the worktree-directory watcher refetches within 250 ms. The renderer read the missing row as a finished delete, so the waiter resolved without the preserved branch (no toast) and a failure in those last steps showed as success; the real outcome was then dropped. The listing fallback exists only for a lost outcome event, so it now applies only after this host's event stream had a gap: a new subscription or a replay after reconnect. * perf(worktrees): let a bulk delete start each same-repo checkout delete once the host accepts the last A bulk delete ran one worktree at a time per repo (stablyai#2259, for packed-refs and ref-lock races in branch cleanup). With Git now deleting each checkout for 20-35 s before the request settles, N worktrees in one repo took N times that. The renderer now queues same-repo deletes only until the host accepts each one; a parent still waits for its nested children to finish. The host serializes the branch cleanup step per repo itself, which also covers removals started by different clients. * test(worktrees): pin the host platform in the mocked removal suites so they pass on Windows Removal now passes -c core.longpaths=true on Windows, so the exact-argv assertions and command-keyed mocks never matched there (17 failures on a Windows host). Pin darwin as the add-worktree suites already do, and drive the one Windows-specific case through the same spy. * test(worktrees): type the blocked git remove result instead of a broad object The anti-slop static-analysis gate rejects `object` parameters. * test(worktrees): clear the changed-code quality gate in the removal suites Merge the duplicate node:fs import, build the mock child without a cast, read worktrees:list rows through one typed helper, and give the remaining casts a SAFETY line. * fix(worktrees): record each background delete durably and finish it after a quit or crash A quit mid-delete left git to finish the checkout on its own while the branch delete and metadata purge never ran; a crash left a normal-looking row. Each accepted local removal now writes a record beside the profile state before git starts, clears it on success or failure, and the host runs the same delete again for any record left at startup, re-deriving what remains from git and disk. An orderly quit stops the checkout delete without waiting for it. * test(worktrees): type the interrupted-removal assertions for the node typecheck * fix(worktrees): finish an interrupted delete that already removed the checkout's .git file Quit stops git worktree remove mid-delete, and Git deletes the checkout's .git file wherever it falls in directory order. Git then refuses the checkout ("validation failed ... .git does not exist") on every retry, so the startup finish failed and the row could never be deleted from Orca. A registered checkout this record owns that has lost its .git file now finishes like an unregistered one: leftover files, prune, then the branch. * fix(worktrees): let Git finish an interrupted delete, and never take a different checkout A quit or crash that stops `git worktree remove` after it deleted the checkout's .git file left a registered checkout Git refuses to remove. The previous fix deleted that leftover inside Orca's process, which is the bulk delete this change exists to avoid (and on Windows the leftover can be most of the checkout). The startup finish now rewrites the missing .git file from Git's own admin entry for that path and lets `git worktree remove --force` delete it. `git worktree repair` is not used: it also re-points every other registered path, including a checkout another repository now owns there. Orca deletes the leftover itself only when no admin entry claims the path. The startup finish forces, so it now leaves the path alone when the checkout there is not the one recorded: a registered worktree on a different branch or head, or a `.git` at a path Git already unregistered. The record is dropped and the card shows why. The record write before Git starts is now bounded (2 s, logged when exceeded) so a stalled disk cannot hold the delete, and the outcome is published before the record's clear reaches disk. * test(worktrees): compare worktree paths by value and tear down with Windows lock retries Git prints forward slashes in `git worktree list` on Windows, so the real-Git removal suites never found a joined path there: positive checks failed and negative ones passed without proving anything. They now compare Git's parsed rows by value. Teardown uses the shared retrying removeTree, since Windows can hold the deleted checkout busy for a moment after Git exits. Adds a relative-path worktree case for the .git restore (skipped before Git 2.48). * fix(worktrees): reply to a worktree delete when it has finished, not on a broadcast event A current client's delete request now waits for the host's background delete and gets its real result (removed, a preserved branch, or the error) as the reply, the way it did before the delete moved off the request. A request that arrives while the delete runs joins it and gets the same result. Every other view keeps reading the host's `removing` marker: the row leaving means the delete finished, and the row listed again without the marker shows "The delete did not finish. Try again." on a card that view had marked Deleting. A request whose reply is lost (a timeout or a dropped connection) settles the same way from a fresh listing instead of reporting a failure. Clients without the background-removal capability (mobile, the CLI, older desktops) are still answered on acceptance and have rows under removal left out of their listings. This removes the outcome on worktreesChanged and everything it needed: the renderer's outcome waiters, early-outcome buffer and TTL, per-host event-gap generations, the request pre-registration, and the accept callback bulk delete used. Bulk delete runs same-repo deletes in parallel only on this machine, whose host serializes branch cleanup per repo; SSH and paired hosts stay serialized. * test(worktrees): type the pending-removal host id in the background-removal suite * fix(worktrees): answer a delete request even when a concurrent removal of the same worktree replaced its record The desktop app's removal and the runtime removal (CLI, paired clients) coalesce separately, so both can be accepted for one worktree. The second replaced the first's record, and the first delete then finished without resolving the request waiting on it, leaving the desktop card on Deleting indefinitely. Each delete now settles the request it was started for. * fix(worktrees): run same-repo removal archive hooks and teardown one at a time on the host Local bulk delete now sends same-repo removals in parallel, so their archive hooks, terminal teardown and preflight ran at once; a hook that writes refs can race the repo's ref locks (stablyai#2259). The host now serializes each local removal up to acceptance per repo, for every client; Git's checkout delete still runs in parallel under the delete limit. * fix(runtime): keep waiting worktree deletes out of a host's foreground call slots worktree.rm now replies only after Git deletes the checkout (up to minutes), so on paired desktop and web each waiting delete held one of the host's 8 foreground call slots, and a bulk delete queued listing refreshes and every other foreground call behind it. Deletes now run in their own lane with the same bound; the 2-slot background lane stays for status polls. * fix(worktrees): join a same-worktree delete accepted while a removal waited its repo turn The desktop app and the runtime (CLI, paired clients, web) check for a running delete before they queue for the repo's acceptance turn. A delete of the same worktree from the other path, accepted while this one queued, was missed: this request re-ran the archive hook, stopped the terminals again and started a second `git worktree remove` on the directory Git was deleting. The queued acceptance now re-checks and joins the running delete. * fix(worktrees): fence a resumed delete's checkout from startup, and drop rows a listing read before the delete finished A delete a quit or crash interrupted took its terminal and file-watcher gate only when the resume job ran, after the first window was shown; session restore could open a shell or watcher inside the half-deleted checkout first, and on Windows that handle can fail the resumed git delete. Loading the records now fences each recorded path, and the resumed job takes the fence over in the same tick it takes its own gate. A listing that read git's registration before a delete finished, and replied after the removal record cleared, returned the row unmarked, so other views briefly showed "The delete did not finish". Listings now capture the pending removals before reading git and leave out a row whose delete finished successfully since; a row whose delete failed stays listed as before. * test(worktrees): keep git's auto-maintenance out of the real-git removal suite CI's Git 2.55 failed the file-pool test in teardown with ENOTEMPTY on the scratch repo's objects/pack after the test body passed: the 3,000-file commit's detached auto-maintenance was still writing a pack. The scratch repo now disables auto-maintenance and auto-gc. * fix(worktrees): one archive-hook approval covers a same-repo bulk delete again Local same-repo deletes now start together, so each queued its trust prompt with a state snapshot taken before the first prompt was answered; approving the first still showed the same prompt once per remaining worktree. The queued check now reads the store when its turn comes.
…i#24716) * ci: defer headless dependency installation until graph analysis is needed * docs: align headless CI rollout with platform and cache policy * test: isolate headless detector output from the parent CI step
* ci: reuse pnpm verification records in Alpine builders * ci: qualify consumers of the verification restore action * ci: match Linux verification cache archive paths
…24705) * fix(tests): resolve watcher crash harness from repository root * fix(tests): rebuild watcher crash harness from current sources * fix(tests): register watcher interruption callback as an owner hook
* ci(release): publish after a skipped orcad template stablyai#24872 skips orcad-template for tags that predate it, but a skipped ancestor skips every job that keeps the implicit success(), so publish-release and the post-release jobs never ran for v1.4.219. * test: brace-free filter in the orcad downstream contract
* Reduce scheduled CI cache warming to every six hours * Document cache warmer recovery interval and measured tradeoff
…#24722) * fix(files): preserve watcher streams across SSH reconnect races * fix(types): prune the checked SSH watcher from suppression baseline * fix(files): retain established SSH watches through connection loss * fix(files): fence initial SSH watcher callbacks across reconnects * test(files): verify guarded SSH watch errors and cancellation
* Keep large Markdown previews responsive * Fix large preview review navigation and Find budgets * Initialize preview scroll caches once and check viewport visibility * Restore large previews after loaded rows are measured * Refresh loaded Markdown rows after viewport changes * Keep Markdown revisions visible and reuse bounded search text
* Skip slower Windows root package-store restores in CI * Update reviewed mobile dependency-store cache expression
…tablyai#24726) * fix(files): retry failed shallow directory watch replacement * fix(plugins): restore development watchers after folder replacement * fix(plugins): reconcile root bindings without broad macOS watches * fix(plugins): avoid source-watch restarts on Windows child edits * fix(plugins): preserve full filesystem identity precision * fix(plugins): recognize root replacement on inode-less volumes * fix(plugins): preserve availability without reliable directory identity
…ts (stablyai#24888) * fix(watchers): preserve full directory identity precision * fix(watchers): recover replaced inode-less directories * fix(watchers): decline unavailable creation-time identities
…tial database is unreadable (stablyai#24605) * fix(opencode-go): stop before OPENCODE_API_KEY when the credential database is unreadable An unreadable OpenCode credential database read as 'no key', so the Go key resolver fell through to OPENCODE_API_KEY, which OpenCode shares with its Zen provider and can show usage for the wrong key. The database read now reports unreadable separately; with an env key set the resolver stops, and the usage fetch uses a configured cookie or shows a readable error. * fix(opencode-go): treat a denied credential-database listing as unreadable, not missing
…eadable (stablyai#24604) An unreadable state DB was read as 'not busy', so the trust grant ran a short app-server RPC that can refresh an abandoned backfill lease. A tri-state pending check lets the trust grant take its fallback, while other callers keep their existing boolean behaviour.
… SQLite reader worker (stablyai#24603) * refactor(sqlite): rename the OpenCode SQLite worker entry to foreign-sqlite-reader (STA-9122) The worker thread that reads OpenCode's database off the main thread is about to read other apps' databases too, so its entry is renamed to what it is: src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts, built as out/main/foreign-sqlite-reader-entry.js. Why now: stablyai#24572 fixed the Cursor focus freeze with a second, dedicated worker. Rather than grow one worker per foreign app, the next commit moves Cursor onto this entry and deletes that worker. This commit is the rename only; stablyai#24572's cursor-desktop-profile-worker-entry lines stay until then. It moves out of ai-vault/ into a new foreign-sqlite-readers/ module because it will no longer be session-scanner code; the module will own the readers, their dispatch, protocol and main-process client. The entry still routes only OpenCode kinds in this commit. The OpenCode dispatch, protocol and process entry stay in ai-vault/ and stay OpenCode-only, because the SSH/WSL relay reader bundles them (build-relay.mjs). Every reference is updated: electron.vite.config.ts input key, knip entry, the plain-node entry guard and its test, the asarUnpack list (the scanner service still spawns this entry under ELECTRON_RUN_AS_NODE), and the electron-builder test that reads the filename. The filename and the beside-or-one-up (Rollup chunks) lookup now live in foreign-sqlite-reader-entry-path.ts, which the OpenCode spawn reuses, plus an Electron-main resolver that uses the packaged app.asar path. * fix(cursor): move the desktop-login read from its dedicated worker onto the foreign SQLite reader (STA-9122) stablyai#24572 fixed the Cursor focus freeze (stablyai#24360) with a dedicated worker (rate-limits/cursor-desktop-profile-worker*.ts). Orca already runs OpenCode's database reads on a worker, and more foreign-app SQLite reads are coming, so keeping one worker per app means one entry, build input, asarUnpack line, knip entry and guard line each. This keeps one pattern instead: Cursor's state.vscdb read runs on the shared foreign SQLite reader entry, and the dedicated worker, its entry and its config lines are deleted. What moves: - The read itself is a pure cursorProfile reader in foreign-sqlite-readers/readers/ (was rate-limits/cursor-desktop-state-db.ts), run only on the worker. A separate dispatch owns the new kinds and refuses an unknown kind. The entry routes OpenCode kinds to the untouched OpenCode dispatch, so the relay's OpenCode reader stays byte-identical. - ForeignSqliteReaderClient gives each reader its own WorkerThreadRequestQueue lane (own lazily started, idle-torn-down thread; one shared factory) with in-flight dedupe per database path. Any failure resolves to the reader's existing failure value and never falls back to the main thread. Kept from stablyai#24572, so every reader gets them: - Await worker retirement before respawning. Worker.terminate() cannot interrupt a native SQLite call (e.g. a WAL-index rebuild), so the old thread lives on until that call returns; respawning at once stacked a new thread on the same work for every timed-out read (stablyai#24572 measured three live workers). This belongs in the shared host, which fire-and-forgot terminate(): LazyWorkerThreadHost now takes awaitRetirement and refuses to spawn until the terminated worker settles, and the queue fails calls closed meanwhile. Opt-in, because pure-JS clients (session scanner abort, port scan) respawn right after an abort. A rejected terminate() also ends retirement, so it cannot latch the reader off (raised in stablyai#24572's review). - 10 s Cursor timeout, 2 consecutive deaths, a queue cap of 8. - stablyai#24572's worker tests, rewritten against the shared client: responsive caller plus coalesced probes, unavailable worker without path leaks, stalled-worker recovery, no respawn before retirement, dispose settles. Tests: reader, dispatch, client (timeout, 10 s default, crash, malformed, unavailable without a main-thread read, dedupe, queue cap, own thread per reader), queue retirement (stalled and rejected terminate), import boundary, and an event-loop test reading a ~50 MB WAL with no -shm on a real worker. * test(sqlite): walk the reader import boundary with the shared source-tree scan (STA-9122) * fix(sqlite): key reader dedupe on a caller-supplied key, not the path alone (STA-9122)
…ablyai#24261) With many tabs open, a change to any one tab (a retitle, an agent finishing, a tab switch, a git status write, or a browser tab update on SSH and web clients) re-rendered every tab in the strip, so the strip stuttered. Each tab is now a memoized row that re-renders only when its own values change, with stable handlers, a stable drag id list and stable drag sensor options. Editor tabs get their own git status, and mirrored browser tabs keep their page-id list while the ids don't change. Part of stablyai#24241: opening, closing or reordering a tab still re-renders every tab once.
…cies (stablyai#24895) * Reuse the headless detector compiler without full dependency setup * Keep optional compiler-cache saves from failing cache warming
…hip the reader worker in orcad (stablyai#24638) * fix(opencode): read the binder's session store on the foreign SQLite reader worker (STA-9122) Before: the OpenCode session binder listed new sessions from opencode.db with node:sqlite on the main thread every 60 s (and on SessionStart kicks), so a large or contended store could stall the app the same way Cursor's did. After: the read is a pure openCodeBinderSessions reader in foreign-sqlite-readers/readers/, run only on the worker. The binder's correlation, pane snapshot and process sweep stay where they were. - The binder round awaits listSessions and re-checks its generation right after, so a stop() during the read discards the round before it touches the unbound map or the watermark. - The client's in-flight dedupe key now includes the cursor, so a stale round from before a restart cannot hand its rows to the restarted round. - Idle teardown is per reader. The binder lane keeps its thread for 120 s, longer than its 60 s poll, so the thread is not respawned every round. - A timeout, crash, malformed reply or unstartable worker resolves to [] (no sessions), the value the old read already returned on failure. - An absent store still reads as [] without a log line, and a permission or corrupt-file failure still logs (kept from stablyai#24577, now in the reader: it stats the path and throws anything but ENOENT/ENOTDIR to the client's log). - The binder lane inherits stablyai#24572's limits from the shared lane: no respawn until a timed-out worker has exited, 2 consecutive deaths, a queue cap of 8. Its timeout stays 60 s, matching its poll. - dispatch switches on the destructured kind, so a new kind without a case still fails to compile. orcad: the hook server runs there too, so orcad now ships foreign-sqlite-reader-entry.js beside orcad.js (ORCAD_ARTIFACTS, built as an orcad child). build-orcad runs a smoke check that starts the built worker under the build's Node and under the pinned runtime, and does a real binder read on a fixture DB, a Cursor read of a missing file and an OpenCode history list. The OpenCode history scanner uses the same entry and was bundled into orcad without it, so on orcad it always failed closed; it can now run. Tests: reader (cursor, same-ms ids, OpenCode 2 rows, missing then created, corrupt, inaccessible directory), retirement gate for the binder lane, dispatch routing, client lane (rows, failure -> [], dedupe per cursor, own thread, idle teardown default and override), binder loop with an async listSessions (failure -> [], stop during the read), orcad path resolution through orcad's host adapters, artifact list, and the smoke check against good, missing and non-reading entries. * test(opencode): cover the binder read deadline with fake timers and name the failure test accurately (STA-9122)
…tablyai#24607) * refactor(ai-vault): delete the unused session-scanner worker thread Production always scans through the forked session-scanner service process; the worker thread was reachable only under NODE_ENV=test or the undocumented ORCA_AI_VAULT_SERVICE_PROCESS=0 switch, and nothing fell back to it on service failure. Remove the thread (spawn, client, protocol, entry, tests), its build entry, knip and plain-node-guard listings, and the backend switch, so session-scanner-background always routes to the service. - Move the scan options type to the service protocol as AiVaultServiceScanOptions. - Tests now mock session-scanner-service-spawn, the seam production calls. - Repoint the hot-path listing reliability gate from the worker-client test to the service-client test, which covers the same bounded-queue, cancellation, and fault-restart properties for the real executor. STA-9122 * test(ai-vault): cover the service's Claude-vs-OMP subagent lister choice Runs the real service entry and subagent reader, replacing only the two per-agent listers, so a swapped lister choice fails. STA-9122
* Add Qoder session history and search with real CLI coverage * Allow the real Qoder marker file to end with a newline * Keep Qoder tool output out of history previews and search * Keep Qoder search pages readable by older clients * Verify persisted Qoder history after a real generated and resumed task * Negotiate Qoder filters before searching an older execution host * Combine search client imports for the CI plugin gate * Keep the relay search oracle aligned with legacy agent filtering * test(qoder): align search capability contracts and pin old-host fencing
…4830) * fix(cursor): show the primary usage pool without hiding exhaustion Use Cursor's reported plan percentage when its base allowance disagrees. Select Cursor Models for compact display while preserving maximum-pool warning, overflow, sorting and collapse behavior. Adopts the plan mapping and primary headline intent from PR23531. Co-authored-by: DakaAlvarez <149860458+Dacadev97@users.noreply.github.com> * fix(cursor): describe compact usage summaries Correct the existing six translations and fallback to describe one summary per provider after the compact Cursor primary-pool adoption. Preserve quota mapping, selectors, alerts, sorting and detailed presentation. Validated source patch: d2a233e5f90a2cf8ccfb02dfafe99e0e03add48d with normal installed commit hooks, localization catalogs and changed-code quality. Standalone copy uses a private index and preserves the reviewed candidate ancestry. Co-authored-by: DakaAlvarez <149860458+Dacadev97@users.noreply.github.com> --------- Co-authored-by: DakaAlvarez <149860458+Dacadev97@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Upstream
Summary
ELI5 The sidebar keeps an amber unread cue on a workspace while an agent is working or waiting for permission. ## What Changed When the new workspace-card style is enabled and Status is shown, the unread dot stays over the existing activity icon. The working spinner and permi
Note
innocarpe/orcamainuntil the upstream PR is merged.