Commit Graph
1955 Commits
Author SHA1 Message Date
Brennan BensonandMerge Sim d07c47593d feat(mobile): structured native Claude chat (#18741)
* feat(mobile): structured native Claude chat

Mobile already spoke the structured agent-session protocol for Codex, and the
host already had a Claude capability gate — mobile just never advertised it, so
`projectAgentSessionTabsOut` stripped every Claude tab before it left the
desktop. The structured lane in mobile/ turned out to be agent-agnostic already
(shared reducer, message projection, option catalog, prompt tokens), so this
opens the gate rather than building a second lane:

- advertise `agent-session.structured.claude.v1`
- resolve any structured provider in `resolveMobileNativeChat` via the shared
  `isAgentSessionHandleProvider`, instead of a `'codex'` literal
- widen the `agent-session` route type off `'codex'`
- route bare Claude launches through `agentSession.createSupport` like Codex,
  which still degrades to a terminal when the host refuses (remote, WSL, win32,
  managed-account mismatch, or structured chat switched off)

Deduplicate the create envelope. Renderer and mobile each assembled the
`agentSession.create` params by hand; the fingerprint has to be computed over
the same fields the host recomputes, so both now build it in one shared
`structuredAgentSessionCreateParams`. Mobile's Codex-only launcher becomes
`createMobileStructuredAgentSession(client, worktreeId, agent)` and reuses the
shared display-name map; two copies of a random-UUID fallback collapse into one.

Answer grouped Claude questions. A Claude AskUserQuestion carrying more than one
question — or one multi-select question — is emitted with the real content in
`body.questions` and the flat `options` left EMPTY, so mobile rendered a card
with nothing to tap and the turn stalled with no way out. Codex never emits this
shape. The phone has room for one question at a time, so the group is answered
as steps and submitted once, reusing the shared
`encodeAgentSessionQuestionAnswers` / `isValidAgentSessionQuestionAnswers`
rather than a second encoding.

Prompt responses move into `useMobileStructuredPromptResponses` because grouped
questions carry a multi-step draft the rest of the session does not touch, and
the session hook was at the 300-line cap.

Pin the mobile capability list against the host's parser bounds: it fails closed
to NO capabilities when the array exceeds 64 entries, which would look exactly
like an old client.

Re-pin mobile-session-route-parity: the create-actions edit drops one runtime
string literal and changes one nested function body. Ablated to confirm that
file is the sole cause.

* fix(mobile): derive the grouped-question draft instead of clearing it in an effect

The React Doctor gate flagged the session-change reset as a state adjustment
after a prop change, which renders the stale draft for a frame. Store the
session the answers were collected in alongside them and check it on read, so a
session switch drops the draft during render with no effect at all.

* test(mobile): pin that grouped steps key apart when the questions read identically

Claude can ask the same text twice in one group (once per file, say). The view
keys the question card by its projected content, so identical wording must still
key apart or step 1's checkboxes would be submitted as step 2's answer.

* fix(mobile): harden grouped Claude question answers

* fix(mobile): retry transient structured support probes

* fix(mobile): preserve grouped prompt response compatibility

* fix(mobile): preserve tokenless duplicate choice identity

* fix(mobile): point the launch tests at the generalized create API

The rebase onto #18697 brought its definitive-refusal tests in cleanly, but they
call the pre-rename createMobileStructuredCodexSession, and mobile tsc excludes
test files so nothing caught it. Retarget them and give the agent-copy test a
code that is actually in the definitive allowlist - agent_session_refused now
correctly stays unknown, so it never reached the failure copy it asserted.

* test(mobile): re-pin route parity after the rebase onto main

Main moved its own runtime-string pin to 547; this branch drops the 'codex'
literal from the create-actions gate. Ablated against main's pins to confirm
that file is the sole cause before re-deriving.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 11:57:21 -07:00
Jinwoo Hong 06a607a1d7 feat(orchestration): make multi-agent workflows durable (#16904)
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->

| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 225 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$​21666 | $\color{#cf222e}{\Huge{\mathbf{−}}}$​2820 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$​18846 |
| Prod | 348 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$​17107 | $\color{#cf222e}{\Huge{\mathbf{−}}}$​4706 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$​12401 |

<!-- /orca-pr-loc -->

## ELI5

Orca now treats orchestration like a durable control plane instead of inferring success from terminal keystrokes. Agents can tell whether a prompt was accepted or a turn started, replay an ambiguous request without sending twice, and recover coordinator mail after a crash. Completed workers can be inspected, released, or retained, and their panes no longer auto-resume as if the work were still running.

## What changed

- **Run receipts** from `run-create/use/current/show/list` are the row without routing plumbing (`home_database`, `coordinator_pane_key`) and without the duplicate `binding` object.
- **`terminal send` receipts are honest and idempotent.** `input_accepted` and `turn_started` are the only stages; `--wait-submit` observes without resending; `--retry-request <uuid>` replays the exact request against the same process incarnation. A transport timeout keeps the retry ID; only a different runtime answering strips it. Value-less or non-UUID `--retry-request` is rejected on the CLI and the SSH shim.
- **Mailbox delivery is committed before wakeup.** Pointer writes are staged in the DB before any PTY byte, replayed once after restart, and never emit a naked Enter. The watermark that parks concurrent deliveries is released with the DB reservation. Restart rescans pointer-pending and `dispatch:` mailboxes.
- **Lifecycle is a guarded transition graph** (`lifecycle-transition.ts`) with a table-driven test over every caller edge. Task reopen/overturn stays in the public contract. A PTY exit during `worker-stop` is the stop succeeding, not a failure.
- **Worker lifecycle CLI:** `worker-start` (`--spec` creates Task + attempt in one call), `worker-show`, `worker-read` (provider transcript first, bounded terminal fallback with a typed reason, local/WSL/SSH), `worker-stop`, `worker-abandon`, `worker-release`, `worker-retain`, `worker-list` (rowid-fenced pagination, fleet liveness, `attention`, literal `nextAction`).
- **Release is an explicit ownership table** (`decideWorkerTerminalRelease`): only an `owned` resource can be settled, the archive is mandatory where reachable, and an owner whose process is proven exited can always get out of `retained` via `archive_status: unavailable`. User-taken-over, external, and transferred panes stay retained.
- **Settled-worker resume fence** (folds in #17651): a settled dispatch whose pane is still open is fenced at settlement, on stop/abandon/exit, and at startup; lifted on release, retain, takeover, and pane reuse.
- **Liveness is `live` / `unverifiable` / `exited` only**, from execution-host evidence. Fleet projection reads the evidence clock, not the relay delivery clock. A host-certified exit outranks the worker's settled state. `unverifiable` never authorizes stop, abandon, retry, or release, in code or in the guide.
- **Federation:** structured reads negotiate by `method_not_found` so every shipped host keeps transcript-first output; exited remote workers are closed before being reported closed; epoch fencing holds across peer restart, downgrade, and pairing rotation; no per-second forced capability probe.
- **Schema v35:** repairs databases stamped v34 by the pre-fix branch (mailbox_handle default, index predicates), drops the write-only `lifecycle_transition_receipts` ledger and five never-read v31 identity columns.
- **Schema v36:** `dispatch:<id>` mailboxes get a real consumer generation on `dispatch_contexts` and `remote_dispatch_attachments`, bumped and fenced in the same transaction on every re-attach (manual inject, worker-start, federated attach). A stale worker whose Dispatch moved to another process now gets `consumer_fenced` instead of silently acking the new worker's Delivery. Run mailboxes already worked this way.
- **Schema v37:** `dispatch_contexts` records its creator (`creator_handle`, `creator_pane_key`), so a coordinator's context-only self-dispatch is bookkeeping rather than a nesting parent; before this, one self-dispatch made every later `worker-start` from that coordinator fail the depth cap. Pre-v37 rows keep counting (fails closed).
- **Dispatch-mailbox ownership is checked, not inferred.** A `check` from a process whose pane no longer holds the Dispatch, or whose last Attempt was abandoned/failed and moved to another terminal, gets `consumer_fenced` instead of an empty inbox that reads as "no mail yet". `--peek`/`--all` stay readable. A paneless caller still gets `stable_pane_required` with the rebind recovery.
- **Liveness certification is stricter:** a `process_exited` stage whose termination reason is `unknown` (a stop that was issued but never observed) projects `unverifiable`, not `exited`. Federated `worker-show` carries the execution host's verdict and host kind instead of a local guess. A live, ready worker with nothing pending has `nextAction: none` rather than pointing at the `worker-show` that produced it.
- **Wire:** `workerShow` keeps `dispatch.task_id` next to `taskId` for shipped CLIs. `ask --json` uses the standard `{ok, result}` envelope like every sibling verb.
- **Migration start-version detection** treats the two v32 recovery columns as versioned. Before this, every shipped database stamped below 32 resolved to the v6 floor and replayed the whole chain (the v23 backfill synthesized 68 phantom retained workers on a real v30 profile). Verified on a copy of a real 62 MB v30 profile: starts at 30, no row delta, integrity ok, 11 ms.
- **Skill guide** rewritten as a ≤200-line kernel plus seven references, to the outcome-first standard (Result / Done / Safe failure first, conditions not case lists, one done bar, references loaded at the point of use). The canonical loop uses `worker-start --spec`, names `worker-list` for completion accounting, documents `--retry-request` / `request-show` / `--wait-submit`, and requires positive evidence before any stall action. The other seven guides get the same treatment in #18724, split out so this PR stays orchestration-only.
- **`rpc/methods/orchestration-*`** (126 flat files) regrouped into `orchestration/{worker,federation,messaging,runs,gates}/`.

## Why

User reports showed the same boundary failures: false `agent_prompt_stalled` causing duplicate sends (#15180), coordinators unable to trust screen scrapes, cold-parked terminals receiving a pointer without the submit, settled workers accumulating as live tabs and auto-resuming after restart, and no way to tell a stalled worker from a working one.

## Linked issues

Fixes #15180. Fixes #17935 (orchestration skill description is 866 characters; a guard now caps every bundled skill at 1,024). Supersedes #17651 (fence folded in). Advances #16660, #16522, #14907, #13047.

## Review record

This PR was reviewed adversarially after revival: eight independent lenses (lifecycle, mailbox, send, worker, federation, transcript, complexity, live ergonomics), each required to prove findings with a failing test. That produced 16 proven blockers, all fixed with red-then-green regression tests, followed by two re-review rounds and a third fix wave that caught 3 regressions introduced by the fixes and 7 fixes that missed their target; all closed. A final pass (five lenses incl. a live built-runtime smoke, then a re-review of the fix wave) found and fixed seven more, chiefly the stale-worker mailbox steal, the self-dispatch depth wedge, and the unproven-exit certification. Three independent Codex (gpt-6-astra) passes followed: the first found nothing new, the second found and fixed 3 defects (task-status reachability, WSL-local host classification, peer-capability epoch), the third found and fixed 6 (production PTY controller never installed settled writes, ambiguous in-flight pointer failures allowed duplicate replay, SSH/relay deadlines cut off a valid `--wait-submit`, stop-vs-exit race during inspection, and two release-recovery paths for vanished or exited terminals). The full record (findings, proof tests, triage, declines with reasons) is archived outside the repo.

**Rework after the live smoke.** A first live cross-host run on the shipped adhoc build (this Mac, a paired Windows host on the same build, a paired Mac on 1.4.195, and an SSH host) found a P1: a running local worker read `unverifiable`/`missing_status` because the fleet snapshot rows lacked the terminal handle the matcher keyed on. A 59-row failure table over every bug fixed during review showed the same two classes recurring: a fact dropped in transit through optional fields, and two authorities for one fact. Two blind designs (Opus, Codex) converged on the same mechanisms, and the scoped tranches landed here with red-then-green seam tests from the real producer to the real consumer, faults injected only at the transport or hook-ingest boundary:

- **Settlement (data-loss class):** one three-valued `WriteSettlement` (`accepted | refused{reason} | unverifiable{reason, bytesHandedToTransport}`) from the SSH multiplexer through daemon client, providers, controller, to pointer staging. No boolean, no rejection-as-third-state. The two silent degrades that fabricated a handoff are deleted; a provider that cannot settle refuses before any effect. Pointer text and Enter share the contract; a partial flush is `unverifiable`, never `refused`.
- **Evidence identity (false-liveness class):** fleet agent-status evidence is a tagged union (`binding: worker | pane | unresolved{reason}`, `clock: observed | delivery`) minted once at ingest, so a hook row captured on one process incarnation can never bind to a later dispatch on the same pane. The matcher's `!worker.paneKey ||` defaults are gone. One host-scope parser replaces two.
- **Small pre-merge items:** `capability_unsupported` from an old peer is no longer relabelled `host_unavailable`; a producer census test asserts every agent-status consumer path projects a pane-only hook row as `live`.

Two ergonomics defects the second live run surfaced on a real database are fixed here too: a pre-v3 dispatch already marked `completed` projected as `outcome_unknown` / `requiresAction: true` forever (three copies of the outcome ladder disagreed on legacy rows; now one resolver, legacy `completed` reads `succeeded` with nothing to act on, legacy `failed` stays actionable on the failure), and an unscoped `worker-list` enumerated the entire database (now defaults to the Run bound to the calling terminal, `--run` overrides, and the receipt's additive `scope` field says which).

A third live round on the shipped adhoc build of `b082443e1f` (same four hosts) plus an unscripted run in the user's own prompt style (a plain Claude Code shell, `/orchestration`, three workers, zero errors, bound-Run default confirmed) found two more branch defects, fixed with red-then-green tests: a worker freshly started on a paired server projected `unverifiable`/`host_indeterminate` with `requiresAction` for ~3 minutes, including after its own `worker_done`, because the host's federation observation returned `missing_liveness_verdict` for any PTY the liveness register had not yet swept (the host now reads a connected pane it owns locally as `live`; disconnected or SSH-scoped panes stay `unverifiable`); and six pre-v3 completed rows still carried an `input` category because settling through the task-status path or `failDispatch` never closed the Dispatch's pending question threads (both paths close them now, and schema v38 closes threads already pending on settled rows). The guide's `worker-start` examples now show `--model sonnet`, since an omitted model inherits the launcher's default.

A Codex adversarial pass on the tranche diff found one real design hole (identity minted at read time instead of ingest, now closed) and two daemon settlement paths that threw instead of settling (fixed). Two `@ts-nocheck` runtime mixins on these paths were extracted into checked modules; the repo-wide `@ts-nocheck` count is unchanged at 171.

Deletions during review: ~1,900 lines (write-only ledger, unread columns, dead v1 archive path, test harnesses shipped in prod, duplicated liveness and state-machine copies, self-capability checks that were compile-time true).

## Testing

- `pnpm typecheck:tsc:node|cli|web` clean
- `pnpm run check:code-quality:changed` 0 findings; `check:react-doctor:changed` 0
- `pnpm verify:bundled-skill-guides`, `verify:skill-bundle-manifest`
- full `pnpm test` on the integrated head: 72,332 pass / 292 skipped; the only failures were three non-PR files (two zsh live-shell suites hit a node-pty spawn-helper ENOENT while a concurrent native rebuild ran, 44/44 in isolation; `release-checkout.unit.test.ts` is a known 30 s load timeout that passes in isolation on `origin/main` too).
- CI on 70b4811267 (rerun, pre-Codex): the only reds are five SSH e2e specs plus `terminal-send-agent-prompt-submit:198`, each shown failing identically on main (main's E2E workflow is red on its last 40 runs). The terminal-send spec is root-caused and fixed separately in #18707. The Windows hook-service flake (#17721) and the federation load flake did not recur.
- Skills: `pnpm exec vitest run` over the skill gate files plus `src/cli`, `config/scripts`, `src/main/skills` pass; live smoke on the built CLI of `skills get orchestration` and `--full` (7 references).
- live headless runtime (`orca-dev serve`, isolated profile): canonical loop, stop, release, archive read, retry rejection, stale-handle check, SIGKILL-and-replay all verified with receipts
- Live cross-host smoke on the shipped adhoc build of `0d465e7931` (this Mac and a paired Windows host on the build, a paired Mac left on 1.4.195, an SSH host): local, paired-new, paired-old and SSH loops all settle; running workers read `live` on every host and `exited` after release; the old peer reads `capability_unsupported` and refuses release honestly. Injected 10 s relay stall with a send in flight: delivered exactly once after recovery, zero duplicates. Every liveness field across 104 receipts is only `live` / `unverifiable` / `exited`.
- Final live cross-host smoke on the shipped adhoc build of `b082443e1f` (same hosts): every loop settles; 942 of 948 legacy completed rows read settled with `requiresAction: false` before the question-thread fix and all of them after; `worker-list` scope reads `bound` / `flag` / `all` correctly; 122 JSON receipts carry only `live` / `unverifiable` / `exited`. Unscripted prompt-style run: clean.
- Confirmation smoke on the shipped adhoc build of `2da076d4e9` (this Mac and the paired Windows host, both updated): a freshly started Windows worker reads `live` on the first fleet poll and on all 20 that follow, with no `host_indeterminate` at any point, and `exited` after release; all 948 legacy completed rows read `requiresAction: false` with `nextAction: none` after schema v38; every verdict across 60 receipts is `live` / `unverifiable` / `exited`.
- Not physically exercised: WSL hosts, the renderer notification bell (headless has no renderer), same-session fence via a real pane close (renderer-only state), restart mid-delivery on a real app (covered by e2e only).

## Notes

- Remote-wire additions are optional fields or `method_not_found`-negotiated methods; one new Electron-only IPC channel (`agentStatus:legacyWorkerTerminalResumeFence`) never crosses the wire.
- SSH contact loss remains `unverifiable`; the execution host stays authoritative.
- Intentional wire projection change: an SSH host scope with an empty `targetId` now projects host id `ssh` instead of an empty string (remote-wire-compatibility rule 3, old clients decode the same field). A fleet pane key without a terminal handle is now `unidentifiable` rather than matched by pane key alone.
- Found live but pre-existing on main, filed separately: a relay daemon-start collision during transport loss rewrites the endpoint credential and wedges the surviving relay (host needs a manual kill); `terminal create` on a reconnecting SSH host reports an opaque `No PTY provider for connection`; `terminal list` reports `orphaned:false` and `terminal close` reports `ptyKilled:true` for a pane whose relay is gone (orchestration's own projection reads `unverifiable` correctly at the same moment).
- Downgrade after this PR is not a supported path: main opens a v37 database and early-returns (its inserts still work against the v36/v37 defaulted columns), but its one-outstanding-Delivery-per-Run index is a no-op against the branch's mailbox-scoped index of the same name.
- Known follow-ups (not blockers): `worker-list` materializes every dispatch row per call; a positive "agent absent" signal distinct from PTY liveness is a product decision left open (a headless fake agent never reaches `live`, so its `nextAction` stays `inspect`); a context-only self-dispatch still lists as `role: worker` in `worker-list`; `dispatch` task-not-found / task-not-ready / inject-rejected still surface as `runtime_error`; task and inbox receipts still expose raw row columns. Deferred skill product decisions live on #18724.
2026-09-06 14:34:03 -04:00
Brennan BensonandMerge Sim 39cbc68f16 fix(native-chat): honor structured routing with saved options (#19040)
* fix(native-chat): keep saved options on structured route

* fix(native-chat): seed structured session options

* fix(native-chat): preserve create wire compatibility

* fix(native-chat): keep create replayable across an option change

Seeding host-resolved options into the attach fingerprint put a mutable
value into the durable operation identity. A create whose outcome was
unknown, retried under the same operation id after the user reselected a
model, re-resolved different options and hashed to a different
fingerprint — so the ledger refused it as a conflict instead of
replaying. That refusal is not definitive, so no legacy fallback fires
and the launch has no recovery.

Options are the session's initial state, not its identity, and the
reservation still carries them to the record. Excluding them also makes
the digest byte-identical to the pre-change one in every case, not just
when no options resolve.

* refactor(native-chat): name the structured launch option seed

The create-intent resolver narrowed saved options to model/effort with an
inline key literal, inside a file carrying @ts-nocheck — so neither the
key list nor the string narrowing had a typechecked or testable home, and
the repo already expresses this concept as a named shared shape.

Move it to resolveStructuredLaunchSeedOptions beside the persisted
settings it reads, where it is typechecked and unit-tested, and document
why the seed is exactly model and effort: they are the only ids the
picker persists that both providers also accept as strings.

No behavior change. Adds coverage for a non-string persisted effort,
which settings.json can hold and the durable record must not carry.

* test(native-chat): name the structured routing pin for what it asserts

The case drives a saved Codex model and effort through the launch path,
but the structured create intent it asserts on carries no options, so it
pins the route and not the preservation its name claimed. Preservation is
pinned host-side, where the seeding actually happens.

* test(native-chat): pin the empty seed the record cannot carry

valuesByModel is merged over the resolved model, so a stored `model` key
can blank it — the seed then empties out and must resolve to undefined.
Nothing covered that branch, so returning the empty map unguarded stayed
green.

It matters because emitting `{ model: '' }` fails the record's
bounded-string guard, and agent_session_options_invalid is not a wire
refusal code: classifyStoreFailure rethrows it, the client reads the raw
error as an unknown outcome, and the launch strands with no fallback.

Corrects eb33ee6bb3, whose message claimed no behavior change. That
extraction also stopped emitting empty and whitespace-only values, which
is a fix on six input shapes, not a pure move.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 02:27:44 -07:00
6494f2a4f0 fix(native-chat): resume a structured chat from Agent Session History (#18933)
* fix(native-chat): resume a structured chat from Agent Session History

Clicking Resume on a chat-UI row could only reveal an already-open tab. If the
chat had been closed, or this process had never published it, the click re-read
an inventory that did not contain it and toasted "Retry in a moment" — advice
that could never come true, because nothing republishes an unpublished tab. The
legacy `claude --resume` fallback is deliberately refused for structured-owned
rows, so the row had no way back at all.

`close` already keeps the record and the journal on disk so a session can be
attached again, and the hold path already resurrects one in full. What was
missing was the tab: `restoreReadableSessions` is latched to run once, at
startup, so nothing could ask for a single session later.

Adds `agentSession.reveal`. The host looks up its own record, restores the
session readable, and republishes the tab through the same call
`agentSession.create` uses. Deliberately narrow:

- It takes no hold. A provider child exists because a surface asked, and the
  chat pane asks when it binds.
- A journal it cannot read is not a refusal. A chat whose journal predates the
  SQLite store restores to nothing here, but attach still recovers it, so the
  tab is published and the pane's hold finishes the job.
- Workspace and provider come from the record, never the client, so a session
  id alone cannot aim the publication at another workspace.

Claude and Codex both, by construction: eligibility is `adapterSupportsRecord`,
which the router answers from the record's own provider.

Gated on a new advertised capability rather than probing for method_not_found,
matching agent-session.structured.hold.v1 — absence is visible during
negotiation instead of by calling.

* fix(native-chat): negotiate reveal against the host that owns the workspace

The capability gate read the LOCAL runtime's advertised capabilities while the
call went to the host that owns the workspace, which for a paired workspace is
a different build. On desktop the renderer and its local host are always the
same build, so the gate passed unconditionally and proved nothing about the
host being called: an older paired host still received the unknown method and
its method_not_found was reported to the user as 'this chat is no longer on
this host'. The cache it read also starts empty and resets to empty when
status.get fails, so 'not fetched yet' and 'unsupported' were the same value.

Gate on the environment that will answer, the way agentSession.close already
does, and skip the round trip entirely for a local host. Reveal now reports
four outcomes instead of a boolean, so a host that is merely too old is not
reported as a chat that is gone, and a host we could not reach keeps the
retryable message.

Also syncs the localization catalog: the 'gone' key shipped without an en.json
entry, which reddens static analysis and verify while typecheck stays green.

* fix(native-chat): tell a refused reveal apart from a missing chat

The host raises two refusals here and they mean opposite things to a user: it
holds no such record, or it holds one no adapter of its own can open. The
client collapsed both into 'this chat is no longer on this host', which is a
eulogy for a chat still sitting on disk. Read the refusal code, and fold the
host-side case in with the too-old host under one honest message, since the
remedy for both is the same.

Adds the coverage the readiness pass found missing: the host's reveal answer
itself (workspace and provider from the record, both refusals, an unreadable
journal, a live session), and the activation branches for a host that cannot
open the chat and for one that never answered.

* fix(native-chat): read a host version block as the host's age, not a lost link

The capability probe reaches assertRuntimeStatusCompatible, which throws a
runtime_compat_block error. Treating that as unreachable told a user with an
out-of-date host to retry, which is the one thing that cannot help. Branch on
isRuntimeCompatBlockError the way remote-agent-session-launch already does for
the same probe.

Also adds the refusal-code case a previous commit claimed and did not deliver:
nothing drove a structured_agent_session_unsupported reply through the reveal
client, which is the branch that commit existed to add. Corrects a doc comment
that reveal made wrong: attach is no longer the only call that builds the host.

* fix(native-chat): let a dragged history row reach the same reveal as a click

Dropping an Agent Session History row onto a pane activated the tab by id and,
on a miss, raised the very toast this PR exists to remove — so the same row
answered a click and a drop differently, and the drop kept the advice that can
never come true. The structured branch never used the drop pane, so routing it
through the shared activation loses nothing and gains the reveal.

The helper only ever read one field, so its parameter narrows to that field and
the drag payload satisfies it directly. A source ratchet holds both entry points
to the reveal-capable path, since a mounted drag harness does not exist for this
layer and what regresses is a call site, not a rendering.

* fix(native-chat): stop an advisory refresh ending the click, and one click per row

Manual QA found the reveal never ran: the inventory refresh that precedes it
is an optimization, but its failure returned early with 'not available yet,
retry in a moment' — reinstating the dead end this PR removes, one step
earlier. A failed refresh now falls through to the reveal, which is the repair
and does not need the refresh to have worked.

The click can chain a refresh, a capability probe, a reveal and a second
refresh, each with its own timeout, while nothing on the row says it is
working. A per-session in-flight guard keeps an impatient second click from
running the whole sequence again and landing its own toast.

Also drops an unreachable owner scope: the snapshot apply discards any
worktree whose execution host is not local before it reads one, so naming a
remote scope there described a synchronisation that cannot happen.

* fix(native-chat): bound the capability probe and stop naming the wrong machine

The in-flight guard releases when the activation settles, so an await that
never settles holds the row for the life of the process. The capability probe
was the one call in the chain not raced against a deadline: on a cache hit it
awaits a promise an earlier probe created, which may carry no deadline of its
own. Race it like the two calls around it.

A version block can name either side — evaluateRuntimeCompat reports
client-too-old as well as host-too-old — so a message that blamed the host
pointed half of those at the wrong machine. Name the remedy instead of the
machine, which is true for every case that reaches it.

* chore: remove a scratch repro file committed by mistake

It was swept into the previous commit by a broad `git add` while a diagnostic
ran in this worktree. It asserts the current renderer-sync defect as expected
behaviour, so it would fail the moment that defect is fixed.

* fix(native-chat): stop a reveal's own inventory refresh discarding its republished tab

Manual QA: the host answered reveal with ok:true and republished the tab, and
the chat still did not reopen — only a renderer reload brought it back.

The renderer publishes under one epoch string for its whole lifetime, and a
frame recorded under a different lineage retires that epoch permanently with
nothing to un-retire it. The Resume click asks for an inventory first, and a
worktree the host holds no entry for answers with the none/v0 sentinel; the
structured path recorded it, retiring the renderer's own epoch, so the tab the
reveal published a moment later was dropped. A reload minted a new epoch,
which is why reloading appeared to fix it.

A frame that carries no publication is not a later publication to fence
against. Treat the sentinel and a removal frame as a cursor reset, the way the
mainstream session-tabs path already clears its tracking — its comment names
this exact hazard: recording that sentinel would retire the host epoch and
reject the next live frame.

Pre-existing, and it swallows an ordinary new-tab launch on an empty worktree
too; the reveal is what turned a silent invisibility into a visible failure.

* fix(native-chat): let a retraction prune its rows without retiring the epoch

Correcting the previous commit. Skipping a retraction frame outright stopped it
pruning the mirrored rows, so a worktree the host no longer publishes would
have kept a chat on screen with nothing behind it. Apply the frame as before
and clear its cursors instead of recording them, which is what the mainstream
session-tabs path does.

The unpublished sentinel keeps its cursor now too: it is skipped rather than
cleared, so a stale frame arriving late is still fenced. Adds the case the
earlier version would have broken.

* fix(native-chat): keep the retraction's fences, and fence the reveal's refresh

Correcting the retraction handling again. Clearing its cursors was more than the
bug needed and cost a guard: the host mints a fresh epoch when it rebuilds a
pruned entry, so a republication is never gated by the retained cursor, while
dropping it left an inventory response issued before the close free to land
afterwards and strand a chat row for a worktree the host no longer publishes.
Skip only the recording. The mainstream path keeps its epoch history for the
same reason, as a tombstone fence.

The test that justified the stronger clearing asserted a host behaviour that
does not exist — a rebuilt entry republishing under the renderer's epoch with a
restarted counter. It now uses what publishStructuredAgentSessionTab actually
mints for a pruned entry, and a new case covers the frame that would strand.

Also fences the reveal's inventory refresh on the sync generation, which every
other caller that applies an inventory already does: structured chat can be
switched off mid-flight, and the answer would otherwise re-seed a row into a
renderer that just discarded them.

* fix(native-chat): drop the retraction's epoch history, keep its version cursor

Third and final shape for this branch, and the only one of the three that holds.

Keeping both maps re-poisons the epoch one cycle later: the consumer here is
also the publisher, so the history's current is the renderer's own lifetime
epoch, and recording the reveal's fresh epoch retires it. The next chat the
renderer publishes is then dropped — this bug again, one close later. Deleting
both loses the guard that stops a frame issued before the close landing after
it and stranding a row nothing republishes.

So: clear the history, keep the cursor. The mainstream path keeps its history
as a tombstone because there the epochs belong to a remote publisher; that
reasoning does not carry to a path that publishes under its own.

Each of the three variants now fails a different test.

* fix(native-chat): a retraction forgets what is current, not the tombstones

The delete lost a fence the cursor cannot replace: the version cursor only
compares within a lineage, so a delayed frame from an already-superseded epoch
had nothing left to stop it putting a chat row back for a worktree the host no
longer publishes. Keeping the record intact had the opposite fault — the
renderer's own epoch is the history's current, so the next frame under any
other epoch retired it.

Clearing only current does neither: noteRetiredValue retires nothing when there
is nothing current, and the tombstones stay. Each of the four shapes now fails
a different test.

* fix(native-chat): narrow the retraction frame through its own type

Typecheck caught what the tests could not: `removed` is not on
RuntimeMobileSessionTabsResult. The repo already names the shape —
RuntimeMobileSessionTabsRemovedResult — so this reads it through a guard rather
than the inline cast the mainstream path uses.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Merge Sim <sim@local>
2026-09-06 00:50:20 -07:00
NaoyaTatetsuandNeil a567e33bf7 feat(github-projects): render Roadmap project views as a timeline (#17795)
* Add roadmap timeline view for GitHub Projects

- Renders roadmap-layout project views as a scrollable timeline with
  date/iteration-based placement, zoom levels, and grouped lanes,
  instead of surfacing them as unsupported
- Derives placement fields from view config or row-carried field
  values since GitHub's API never exposes a roadmap's date source
  directly
- Falls back to the existing table list when no field can place items

* Fix roadmap timeline edge cases: reject invalid calendar dates and refre

- parseRoadmapDate previously let Date.UTC silently normalize overflowing
  dates (e.g. 2026-02-30 → Mar 2); now round-trips components to reject them
- ProjectRoadmap's "today" marker was frozen at mount, so panes left open
  across midnight showed the wrong day; now re-derives and re-arms a timer

* fix(github-projects): center roadmaps when dated rows arrive

* fix(github-projects): keep pinned roadmap header opaque

* fix: remove stale pnpm executable lockfile entries

* fix(i18n): retain replaced project labels in runtime catalog

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-06 00:03:41 -07:00
Neil a63a4579cf Let worktree creation proceed during stale preparation reclamation (#18967)
* Stop obsolete worktree preparations when evicted or expired

* Let worktree preparation proceed during stale reclamation

* Verify creation during stalled stale worktree reclamation

* Preserve preparation ownership until Git removal starts

* test: keep artifact share fixtures unexpired across calendar dates (#18955)
2026-09-05 22:30:01 -07:00
Neil 64a449df4e perf(search): assemble fragmented subprocess lines incrementally (#18973) 2026-09-05 21:56:15 -07:00
OrcaWinandOrca Worker c252d855ac fix(windows): resolve npm/pnpm .cmd shims past cmd.exe (#17869)
* fix(windows): resolve npm/pnpm .cmd shims past cmd.exe

A `.cmd` target forces every spawn through `cmd.exe /c` with each argument
caret-escaped, and Microsoft Defender for Endpoint scores a long `cmd.exe /c`
line carrying caret-escaped natural language as obfuscation. `codex.cmd` is
named in the spawn cluster of the MDE incident this addresses.

npm's `cmd-shim` and pnpm's `@zkochan/cmd-shim` generate files whose whole body
is "find node, run this script". Read one, and the spawn can go straight to
`node.exe <script> <args>` — no cmd.exe, no caret escaping. Anything the parser
does not recognise exactly, or whose target cannot be confirmed on disk, keeps
the existing cmd.exe path.

Incidentally fixes a real bug: cmd ends its command at a raw CR/LF whatever the
quote state, so a multi-line agent prompt through a `.cmd` shim had to be
rejected. Resolved shims have no such limit.

* fix(windows): refuse drive-relative shim paths and run the win32 tests in CI

Two blocking findings from review.

A drive-relative path defeated the absolute-path guard:
`win32.isAbsolute('D:evil.js')` is false, but `win32.resolve` reads the drive
letter and lands on `D:\evil.js`, outside the shim directory. cmd would have
built `C:\shim\D:evil.js` and failed; we would have executed the wrong file.
Adding `:` to the unsafe-character set closes it, and the alternate-data-stream
spelling `a.js:zone` with it. It costs no coverage: 84 of the 91 real shims on
this box still resolve, the same seven fall back.

Neither `windows-cmd-shim-resolution.test.ts` nor its `.win32` sibling was in
the Windows package job's file list, so the whole filesystem/resolution half and
the real-spawn equivalence suite ran nowhere. Both are now in
`WINDOWS_PACKAGE_TESTS` and in the pr.yml step.

Also from review: clear `windowsVerbatimArguments` explicitly on the resolved
branch rather than inheriting it, since there is no caller-built command line
there; document the kill switch and the PTY/hook-wrapper scope limits in
docs/reference; and cover drive-relative, BOM, line-ending, casing and `%*`
tampering in the platform-independent half of the tests.

* docs(windows): justify the shim-path colon guard from the filesystem rule

The guard was argued empirically ("none of the 91 shims on this box has one"),
which invites a future reader to relax it for a shim we have not seen. Windows
reserves `:` within a path segment, so a relative path cannot carry one at all:
the only spellings that can are drive-qualified, an alternate data stream, or a
`\?\` device path, and the last is already refused as absolute. That makes a
false refusal impossible rather than unobserved.

* refactor(child-process): move resolveSpawn into its own module

The merge with main pushed run-process.ts one line past the 300-line cap:
both sides grew it. The spawn-argv decision is already a pure, separately
tested unit, so it moves out rather than the cap moving up. run-process.ts
re-exports it, so no caller changes.

* perf(child-process): cache the shim interpreter lookup

The parse cache spared the shim read but not the PATH walk, so a second
resolution of the same .cmd did 0 reads and one statSync per PATH entry --
30 on a 30-entry PATH, synchronous on resolveSpawn, where one dead network
mount blocks the calling thread on every spawn.

Keyed by shim directory AND PATH, since the shim's own rule is
%~dp0\node.exe first then PATH, and a PATH edit between spawns must miss.
Corrects the stat comment, which accounted only for the shim itself.

* fix(child-process): revalidate a cached shim interpreter before using it

The node cache was held for process life and never rechecked, so a cached
node.exe that was later uninstalled -- or dropped from PATH by a version
manager -- was still handed to resolveSpawn, failing the spawn with ENOENT.
An uncached process in the same state returns null and falls back to
cmd.exe successfully, so the cache was strictly worse than no cache.

One statSync on a non-null hit, not one per PATH entry, so the walk this
cache exists to skip is still skipped. The stale-null direction stays
uncorrected on purpose: it only keeps the working cmd.exe fallback. Both
directions are now stated in the comment, along with the known miss for
callers that vary PATH per spawn.

* fix(child-process): honour PATHEXT when resolving the shim interpreter

The doc claimed a node.com/.bat/.cmd on PATH returned null and fell back to
cmd.exe. The scan actually skipped those entries and kept looking for a
node.exe, so PATH=C:\A;C:\B with C:\A\node.com and C:\B\node.exe resolved to
B's node.exe while the shim runs A's node.com -- a different binary, chosen
silently, on the one axis this module must not get wrong.

The scan now follows cmd's rule: first PATH directory holding any PATHEXT
spelling wins, PATHEXT order decides within it, and only an .exe winner is
returned. Anything else gives up and keeps the cmd.exe path, which restores
the strict-subset-of-cmd property everywhere except the documented cwd case.

PATHEXT is read from the child's env and joined into the cache key, since it
now changes the answer. Costs one stat per PATHEXT entry per node-less
directory, paid once per process behind the cache.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:33:16 -07:00
OrcaWinandOrca Worker 0b6308f4d5 test(child-process): lower DIRECT_IMPORTER_PIN to the ground two PRs took (#19009)
#17861 and #17884 each migrated one file off node:child_process and each
lowered the pin 158 -> 157 independently. Together they took two, so the
true count is 156 and the two-sided assertion fails on main.

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:33:08 -07:00
0cbb01ef4b fix(security): apply the Windows path-hardening ACL that never ran (#17884)
* fix(security): apply the Windows path-hardening ACL that never ran

`buildWindowsRestrictAclArgs` invoked the hardening script as
`powershell.exe -Command <script> <path> <sid> <isDir>`. `-Command` does
not populate `$args`; it appends the trailing tokens to the command text.
The script therefore read `$args[1]` as `$null`, threw `NullArrayIndex` at
`$allowedSids[$sidText] = $true` under `$ErrorActionPreference = 'Stop'`,
and exited 1. Both callers swallowed that: the async callback was empty and
`applySecurePathRestriction` returned `true` regardless, while the sync
`catch` returned `false` and nobody logged. Every Windows secure path has
been left on its inherited ACL since the ACL was introduced (#5006), and
nothing said so.

Replace PowerShell with `icacls.exe`, which takes plain argv. That removes
the quoting surface entirely rather than escaping it: interpolating a path
into the command text would have turned a dead no-op into arbitrary
PowerShell on a filesystem path, since `-Command` executes what it appends.
It also drops the execution-policy dependency and the `powershell.exe`
spawn an EDR flags, and runs ~25x faster than the PowerShell cold start.

Hardening is now three passes: `/reset` to purge explicit ACEs that
`/inheritance:r` leaves behind, `/inheritance:r` plus a `/grant:r` per
allowed SID, then a read-back that checks the DACL is protected and grants
only the intended rights. The predecessor's verification block was equally
dead, and an apply that is never read back is only half a control.

Failures stay non-fatal — non-NTFS volumes, network paths and restricted
tokens fail legitimately and must not break startup — but they are no
longer invisible: every failure is logged, and a failed async apply now
evicts its cache entry so the next call retries instead of trusting a
success that never happened.

Routing through `runProcess`/`runProcessSync` also retires this file's
`node:child_process` allowlist entry.

* fix(security): verify the hardened ACL by identity, not by shape

Review found the bug class this PR fixes surviving inside the fix. The
verify pass checked rule count, absence of the inherited marker, and exact
rights — never *who* the rules named. Granting Everyone full control
satisfies all three, so hardening reported success on a DACL that handed
the credential to every local account, and most of the real-filesystem
tests still passed.

Verification now reads the descriptor back with `icacls /save`, which emits
SDDL with raw SIDs, and compares the principal set exactly. That is also
locale-independent by construction: the previous parse read localized
account names out of icacls' OEM-codepage stdout, where a non-ASCII path
survived by accident rather than by the documented mechanism. SDDL parsing
moves to `windows-security-descriptor.ts`.

Two further self-inflicted problems, both measured:

The post-rename re-harden led with `/reset`, which re-widened a DACL that
was already correct — the staged file's protected DACL survives the rename,
so the pass had nothing to do but open a window. Polling an external
process during a write into a relocated root caught it: the e2ee keypair
dropped to `BUILTIN\Users:(RX)` plus `Authenticated Users:(M)` — read *and*
write — before tightening again. Hardening now verifies first and returns
early when the DACL already reads back correct, which closes the window and
cuts the steady state from three spawns to one. Re-measured: 158 samples,
one DACL state, zero broad.

Evicting the cache on every failed async apply reintroduced #4901. The env
store re-hardens on the read path at ~2/s, so on a host where hardening
cannot work (FAT32, network path, restricted token) that was two icacls
spawns and two warnings a second, forever. Async retries now take a retry
floor and a hard per-path attempt cap. The write path keeps retrying
unthrottled — it is user-driven, and a failed credential ACL must still be
retried on the next write.

Also: failures route through a reporter hook that the main process points
at the diagnostic tracer, because `console.warn` reaches nothing in a
packaged GUI-subsystem build; `writeSecureFile` returns whether hardening
took, and the async branch reports `pending` rather than claiming `applied`;
a transient `whoami` failure no longer disables hardening for the process
lifetime, and the SID is shape-validated; the `/c` guard now covers the
synchronous runner too.

* fix(security): re-probe hardening instead of latching a transient failure

The per-process attempt cap added for the read-path storm was a permanent
latch: one AV scan, momentary lock or %TEMP% blip and every later credential
write in that session went unhardened, silently, on a host where hardening
would now succeed. Same defect class as #17858's computer-use host, and
worse here because what stops happening is security hardening on credential
files and nothing said so.

The retry budget now bounds the *rate*, not the lifetime: at most three
attempts per path per minute, re-probing in every later window, forever. The
transition is announced in both directions — `throttled` once per window on
entry, `recovered` when a rate-limited path hardens again — so a host stuck
in the degraded state is diagnosable rather than merely quiet. The reporter
type covers both, and the main process ends the `recovered` span
successfully rather than failing it.

Extracted to secure-path-hardening-retry-budget.ts, which keeps
secure-file.ts under its line cap without a max-lines disable.

Also confirms the second flagged risk rather than assuming it: a real
unwritable %TEMP% is now covered by a test proving verification fails
closed, reports at the `verify` stage, and still leaves the ACL applied —
so that path loses proof, not protection, and with the lifetime cap gone it
can no longer combine into a permanent-off state.

* fix(security): verify a directory's whole inheritance flag set

The flag check tested only that `OI` was present — never that `CI` was, nor
that nothing else was. That was harmless while `/reset` + `/grant` ran on
every pass and repaired whatever was there. The verify-first short-circuit
made it load-bearing: what verification accepts is now left alone, so a
latent under-check went live because a different fix started depending on
it.

Two directory DACLs passed while being wrong — both protected, three
non-inherited full-control rules, correct SIDs, differing from correct only
in their flags:

  (OI)(F)        - no CI, so subdirectories are left unprotected
  (OI)(CI)(IO)   - inherit-only, so the directory object itself grants
                   nobody anything; the next writeFileSync into it fails
                   with EPERM, on a directory just cached as hardened

Verification now compares the whole flag set, which also rejects IO and NP,
and names the offending flags in the failure. Both shapes are planted in
real-filesystem regression tests, including an assertion that a write into
the repaired directory succeeds and its child inherits. Confirmed both tests
fail against the old check and pass against this one.

* fix(security): back the hardening retry off exponentially

The fixed one-minute window bounded the retry rate but left a standing floor
of three attempts per path per minute on a host where hardening can never
succeed — FAT32/exFAT, a network path, a redirected profile. That budget is
per path and there are several secure files, so the floor multiplied into
tens of thousands of icacls spawns a day for work guaranteed to fail.

The delay now doubles after each consecutive failure, from a one-minute
floor to a thirty-minute ceiling, and the attempt cap is gone entirely: once
the backoff elapses the path is re-probed however long it has been failing.
A permanently incapable host settles at ~2 attempts/hour.

Slowing the backstop costs almost nothing, because it is not the recovery
mechanism: the synchronous write path is deliberately unthrottled, so a host
that recovers hardens on its very next credential write regardless of what
the read-path budget says.

The `throttled`/`recovered` reports are unchanged and matter more here,
since the quiet periods between probes are now much longer.

The curve is pinned in a new unit test against the exported delay function
rather than a copy of its constants, covering the doubling, the ceiling
holding at 5000 consecutive failures, a 30-day failing path still
re-probing, one announcement per degraded episode, and per-path isolation.
The integration tests keep only what they uniquely prove: that the read path
is wired to the budget, and that a day of failures still re-probes.
Confirmed four of these fail against a reinstated lifetime cap.

* ci(windows): run the real-icacls DACL suite in CI

The win32 suite only self-skips off Windows, so it passed vacuously in
every lane. Register it the way the cmd-shim suite is registered.

* fix(security): describe the cache's real cost, which is icacls now

Both cache comments still justified themselves with PowerShell -- "~1-1.5s" and
"a PowerShell spawn every read" -- in the same file whose PR removed PowerShell
from this path. The caches are still right, but for different numbers, and the
old ones are the kind an engineer would reasonably delete a cache over.

The real shape: hardening verifies first and returns early, so an already-correct
DACL costs one synchronous icacls spawn and a rewrite costs four (verify, reset,
grant, verify). Still worth caching on the read path, which polls at ~2/s.

* test(security): make the DACL suite safe to schedule

Registering this spec in the Windows lane put it under two rules it had
never been measured against.

Teardown now goes through `removeTreeSync`, which the lane's boundary test
requires, and repairs the DACLs the suite plants on purpose first: those
retries only cover transient locks, so a regressed `(OI)(CI)(IO)` repair
leaves the root un-removable and `afterAll` throws EPERM.

And the no-permission case decides by elevation before it writes anything.
`windows-2022` runs elevated, where hardening succeeds: the old branch
asserted nothing about denial and instead replaced the `hosts` DACL, then
`icacls /reset` -- which is not a restore, it drops the explicit
`SYSTEM:(F)` that file ships with. Ephemeral in CI; permanent for a
developer running the lane from an elevated shell. Now it asserts or it
skips. The probe reads the token integrity SID rather than `icacls /save`,
which succeeds unelevated (`BUILTIN\Users:(RX)` carries READ_CONTROL) and
would have skipped the case on every machine.

* fix(security): measure the hardening latches on a clock that cannot go backwards

`mayAttemptHardening` compared wall-clock times, so any backwards step --
an NTP correction, a VM snapshot restore, a user changing the clock --
made the elapsed time negative and held every failing path below its delay
until the clock caught up. Measured at the 30-minute ceiling with the clock
stepped back a year, the path was refused at +0d, +1d, +30d, +180d and
+364d, and re-probed only at +366d. That is the permanent latch the
exponential backoff was added to remove, and it contradicts the module's
own "bounds the rate without ever bounding the lifetime".

The SID lookup's own one-minute window had the identical shape and is
worse: a failed lookup makes `planFor` return null, which disables the
synchronous *write* path too, so the write-path exemption that recovers
the read-path budget cannot recover it. Both now measure elapsed monotonic
time, following the repo's existing `monotonicNowMs` spelling.

Two things the write path was not doing, both found in the same pass:

- A successful synchronous apply now records the outcome. It is exempt
  from the budget, but it was also invisible to it, so a host that had
  demonstrably recovered kept the read path backing off for up to 30
  minutes and no `recovered` transition ever came from that lane. Only
  success is recorded; recording failure would put the exempt lane back
  under the budget.
- `writeSecureFile`'s JSDoc now says its boolean covers the file only. The
  directory harden is fire-and-forget and answers `pending` on Windows
  regardless, so a `true` says nothing about the directory's ACL.

* fix(security): stop the hardening test doubles from faking a no-op

Three CI failures on this branch, one failure shape: hardening silently
does nothing and the check that should have caught it agrees.

The auth critical-path test hand-rolled a `node:child_process` factory with
`execFileSync`/`execFile`. The rewritten ACL path goes through
`runProcessSync`, i.e. `spawnSync`, which the factory never returned — so
every spawn threw into the SID lookup's bare catch, `planFor` returned null,
and hardening no-opped. It mocks `child-process/run-process` now, the
boundary production code actually calls and the one sibling ACL tests
already mock: an export missing there fails loudly by name instead of
returning undefined. Its fake icacls writes a real UTF-16LE SDDL file, so
the pinned spawn count per write is a property of the ACL path rather than
of the double. The test forces `platform='win32'`, so this failed on every
platform, Linux CI included.

`windowsSystem32Binary` is a production bug, not a test bug: it builds a
Windows path with the host `join`, which off-platform yields the mixed
`C:\Windows/System32/whoami.exe`. On Windows the two joins agree, which is
why it survived; on Linux the SID lookup's whoami match missed and 27 of
secure-file's 32 tests exercised a lane that never ran. These are always
Windows paths, so `path.win32.join` is what it should have been.

The import-boundary pin still read 160 after this branch migrated
secure-path-windows-acl.ts off `node:child_process`; the ratchet correctly
refuses a pin left above reality.

* fix(security): resolve the machine-relative SDDL alias, and stop a denied read destroying the file

Path hardening verified the DACL it wrote by comparing the SIDs `icacls /save`
reports. SDDL substitutes two-letter aliases for well-known SIDs, and the
resolution table could only hold constants -- but `LA` and `LG` name an account
by RID inside the *machine's own* SID, so on a box whose user is the built-in
Administrator (a CI runner, an Administrator-only install) the current user read
back as `LA`, matched nothing, and hardening reported failure for every path.
Resolve those two against the machine authority derived from the user SID;
without one they stay unresolved and the comparison still fails closed.

Three secret stores treated any read failure as "malformed -- regenerate" and
overwrote. A hardened file granting a SID this process does not hold reads as
EPERM while its directory stays writable, so the overwrite succeeds: renaming
over an unreadable file needs FILE_DELETE_CHILD on the parent, not DELETE on the
file. That destroyed the E2EE secret key, every paired device's bearer token,
and the plugin vault. Distinguish EPERM/EACCES from a parse failure and refuse.

Also close the async lane's unhandled rejection: `void p.then(onSettled)` turned
a throw from `onSettled` into a dead main process, and the retry budget it calls
threw whenever nothing had configured it -- a contract held only by import
order. The budget now defaults its own bounds.

* test(windows): say which ACEs icacls listed when a planted DACL fails

`toHaveLength` reports only a count and vitest elides the array, so three
preconditions failing on the CI runner said "expected 3, got 6" and nothing
about what the sixth entry was. Name the entries in the failure.

* fix(security): stop three more stores overwriting what they were denied

Same swallow-default-overwrite shape as the readers already fixed, found by
sweeping every store that reads under a hardened root.

- plugin-storage-store.ts returned `{}` on any read failure and set()/delete()
  wrote it back, losing the plugin KV store. It is the secrets store's shape
  line for line, so the two now behave identically.
- relay-revoke-outbox.ts returned [] and save() wrote it, dropping revocations
  that never reached the relay -- a revoked device stays live.
- profile-cloud-session-store.ts mapped an EPERM read onto `decrypt-failed`,
  which fails the `status === 'found'` guard in clearCloudSessionIfUnchanged and
  falls through to an rmSync of the account session. A denied read now reports
  `unreadable`, which licenses nothing; the refresh path bails on it and the
  auth status surfaces it rather than reporting a bare reconnect.

All reuse isPermissionDeniedError. The predicate stays an EPERM/EACCES allow
list rather than "ENOENT defaults, everything else throws": these stores are
meant to self-heal a truncated or malformed file, and inverting it would turn a
corrupt keypair into an app that cannot start. The distinction that matters is
"could not read it" versus "read it and it was garbage".

* test(windows): plant fixture DACLs that cannot inherit what they did not plant

%TEMP% grants [SYSTEM, Administrators, <user>] (OI)(CI)(F) by default, and those
propagate into every fixture. Three preconditions read back 4 and 6 ACEs where 3
were planted, and the extras looked like Orca's own hardening because the shape
is identical -- on a runner whose user is the built-in Administrator, the
inherited trio IS the trio production grants.

Combining /inheritance:r with /grant:r leaves the argument order to icacls, and
that combined form drops the inherited ACEs on Windows 11 but keeps them as
explicit ones on the Windows Server runner. Removing inheritance in its own
invocation makes the grant the whole DACL on either host, and the fixture root
is de-inherited once up front so nothing propagates in.

Rooting the fixtures outside %TEMP% would not have fixed this: any directory
inherits from wherever it lives. The fix is to stop inheriting, not to move.

No assertion is relaxed -- the counts stay exact.

* test(windows): pick a foreign SID that stays foreign on an elevated runner

`S-1-5-32-544` is only foreign to a token that is not an administrator. The CI
runner is elevated AND logged in as the built-in Administrator, so granting
Administrators granted the reader full control: the file stayed readable, and
all six preservation assertions went vacuous rather than proving anything.

BUILTIN\Guests is resolvable everywhere and no interactive token is a member,
so the read is denied on an unelevated developer box and on the runner alike.
An unresolvable SID would have been the stronger choice but icacls rejects one
with ERROR_NONE_MAPPED (1332).

The premise guard is what caught this -- it asserted the file was actually
unreadable instead of trusting the grant, and named elevation as the suspect.

* fix(security): refuse on any read that never reached the contents, not just a denied one

isPermissionDeniedError becomes isUnreadableError, because "permission denied"
was never the concept -- "could not read it", as opposed to "read it and it was
garbage", is. EBUSY, EMFILE, ENFILE and EIO say exactly as little about a file's
contents as EACCES does, and they fell into the branch that regenerates and
overwrites. On Windows EBUSY is the likelier of the two: antivirus holding a
credential open at the moment of a startup read produces it, which makes it a
commoner path to the same permanent loss than the ACL case that motivated the
original fix.

Still an allow list, deliberately: ENOENT keeps licensing a create, and a parse
failure keeps self-healing. The stores are built to recover from a truncated
write, and turning that into a refusal would trade a recoverable state for an
unrecoverable one on the startup path.

Also fixes the regression suite's own premise on an elevated runner:
makeUnreadable combined /inheritance:r with /grant:r, and that form keeps
%TEMP%'s inherited [SYSTEM, Administrators, user] as explicit ACEs on Windows
Server -- so the file stayed readable and all six assertions were vacuous. Same
split-the-invocation fix as the ACL suite's planter.

* test(windows): skip the preservation suite where a read cannot be denied

An elevated token logged in as the built-in Administrator reads straight through
a DACL that grants it nothing -- confirmed on the CI runner against both
BUILTIN\Administrators and BUILTIN\Guests, and with the grant split into its own
icacls invocation so the DACL really was the planted one. On such a host the
premise these tests rest on does not hold, and every assertion would pass while
proving nothing.

So probe once at module scope and skip rather than assert vacuously -- the same
trade the ACL suite already makes for its unelevated-only case. The gate stays
in the compound `<win32 check> && <flag>` form the win32 lane ratchet detects, so
the file stays registered in both lane lists.

Coverage is not lost where it counts: isUnreadableError has unit tests that run
on every platform and every host, and the stores' refusal is exercised in full on
any machine where a denial is reproducible -- which is every developer box.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-05 21:13:06 -07:00
OrcaWinandOrca Worker 975bbdedcc fix(windows): scan ports natively instead of encoded PowerShell (#17861)
* fix(windows): scan ports natively instead of encoded PowerShell

Microsoft Defender for Endpoint scored the relay's Windows port scan as
suspicious PowerShell plus network discovery (T1049). The command line was
`-ExecutionPolicy Bypass -EncodedCommand <base64>` around a
Get-NetTCPConnection/Get-Process join -- base64 next to a policy override is
the highest-weighted token pair on a PowerShell command line, and netstat only
ever ran as its fallback.

Invert the chain. `netstat.exe -ano` is now the primary reader and the owning
process name comes from the shared native process table, which exists to keep
PID lookups off PowerShell. The payload survives only as a last resort, and
without the override: execution policy gates script files, never `-Command`,
so nothing needed it (verified: `-ExecutionPolicy Restricted -Command` runs).

Drop `-p tcp` while inverting: on Windows that protocol name means IPv4 only,
so as a primary reader it would have hidden every `[::]` listener the payload
used to report. Names arrive as `sshd.exe` from the table and are published as
`sshd`, keeping the sshd filter and old clients' rendering intact.

Routes both spawns through runProcess, removing the file from the
child_process and windowsHide ratchets.

* fix(windows): read netstat state by shape and refuse a truncated table

Review of the port-scan inversion found two ways the new primary path could
be silently wrong, both of which would have kept the flagged PowerShell
payload running on exactly the hosts this change targets.

`LISTENING` is not in netstat.exe. It lives in System32\<locale>\netstat.exe.mui
and MUI selection follows the UI language, so the pinned-locale env in
relay-command-env.ts cannot reach it -- a German host prints `ABHOEREN` and the
word test parsed zero rows. The zero-listeners guard then read that as a
blocked reader and ran `Get-NetTCPConnection` every 12-30s forever, or returned
nothing at all where PowerShell is also restricted. Keep the word as the fast
path and, when it finds nothing over output that did contain TCP rows, re-read
by shape: only a listening socket has no peer. Measured on this host across all
four states present (LISTENING 47, ESTABLISHED 49, CLOSE_WAIT 29, TIME_WAIT
213): zero non-listening rows with a zero peer, zero listening rows without
one, and the same 47 rows parse after substituting the German state words.
Shape stays the fallback because `BOUND` also prints a zero peer.

Truncation was invisible: createOutputSink discards overflow, ProcessResult
carries no flag, so a capped read still exits 0 and its head still parses.
netstat orders IPv4 TCP, then IPv6 TCP, then UDP, so a host with tens of
thousands of TIME_WAIT rows would have lost every `[::]` listener -- the exact
loss dropping `-p tcp` exists to prevent, and one the zero-listeners guard
cannot see. Refuse the read instead. A `truncated` flag on the shared sink
would be cleaner and is left as a follow-up rather than widened into this PR.

Also: decline to wait on the shared process table once the request is aborted
(it takes no signal and must not be cancelled for other callers); note the
name lookup as best-effort, since a TTL-cached snapshot can hand a recycled
PID its previous owner name; log once on either fall-through, because both are
permanent and invisible when wrong; and drop a stderr assertion that any
PowerShell autoload banner would redden.

Correcting the cost claim in the previous commit: the aggregate win holds with
the native addon (netstat 21ms vs the retired payload 860ms at 532 processes),
not without it. The addon is optional, the snapshot TTL is 500ms and the scan
cadence is 12-30s, so a relay with no active agent pane never warms its own
cache and pays ~1.4s cold on the CIM path -- slower than what it replaced.

* fix(windows): log the port-scan fall-through on the relay diagnostic stream

Checked where this code actually runs before trusting the log. `console.warn`
did reach a file, but relayLogLine is the right call and the reasoning is worth
recording.

`scanWindowsListeningPorts` runs only in the detached relay daemon: relay.ts
returns early for --connect and --orca-cli, so PortScanHandler is reached only
through runRelayDaemon, and both launchers start it detached with a log file
(POSIX `> relay.log 2>&1`, Windows `1>relay.log 2>relay.err.log` via
Win32_Process.Create). installRelayLogRotation then wraps both streams into
relay.log, which is the file the documented diagnostics tail reads. Verified by
installing the real rotation over a temp path and reading the file back.

So the line surfaced -- but untimestamped, in a log whose format exists so
reconnect flaps can be correlated with the events around them (#7773).
relayLogLine is that format and the relay idiom in 41 other places, and
"since when has this host been stuck on PowerShell" is most of what this line
is for. The test spies on process.stderr to pin the stream and the ISO stamp
rather than just asserting something was called, since a fall-through logged
somewhere unread is the failure being guarded against.

Also fixes a comment that ended its own block early: `relay-*/relay.log` in a
doc comment contains `*/`.

* fix(windows): keep the dominant zero-peer state when reading a localized netstat

Shape alone promoted any zero-peer TCP row, not just listeners. `BOUND` and
`CLOSED` print a zero peer too, and on a localized host their state words are
exactly as unreadable as the listening one -- so a German host with listeners
plus one BOUND socket published a phantom listener. Reachable on an English
host too: with zero listeners a lone BOUND row is promoted AND, because the
result is then non-empty, it suppresses the blocked-reader fall-through.

Group the zero-peer rows by state word and keep only the largest group. A
transient BOUND or CLOSED socket cannot outnumber the listeners (51 against 0
on this host), so this removes the class rather than special-casing the words,
which would just be the localization bug again. An exact tie keeps every tied
group rather than guessing -- no worse than reading shape alone.

Verified against real netstat output: injecting a BOUND row into the localized
capture leaves the result identical to the English answer (47 rows, no phantom
65001). The new test has teeth -- reverting the grouping fails it and nothing
else.

Corrects two claims that were slightly wrong: the docblock said shape was the
fallback because BOUND prints a zero peer, which described the hazard without
saying it was unhandled; and a test comment said an English host "never sees a
bound socket", true only when it has at least one readable LISTENING row.

Also gates the fall-through log per reason instead of per module, so a host
that parses nothing today and truncates tomorrow reports both faults. Same
one-shot cost, and the vocabulary is two fixed strings so the set cannot grow.
That guard matters more than it looks: --log-file rotates stdout only, so the
file stderr can land in is unrotated.

* docs(windows): note the direction the zero-peer majority rule can fail in

The docblock described the tie case and stopped there, which reads as a
complete account of the limits when it is not: a majority rule inverts if the
majority is wrong, and enough transient zero-peer sockets would publish the
phantoms and drop the real listeners. Someone would reasonably have concluded
the rule was safe in both directions.

Trigger numbers and the repro stay in the PR discussion; the code only needs
the reader to know the rule has a direction, and the hatch (defer to the
PowerShell reader, which reads the state word instead of inferring it) since
that is the part a future editor would otherwise re-derive.

* ci(windows): run the real-netstat port scan suite in CI

The win32 suite only self-skips off Windows, so it passed vacuously in
every lane. Register it the way the cmd-shim suite is registered.

* test(windows): lower both child-process ratchets to the ground this PR took

Migrating the port scan off `node:child_process` onto `runProcess` drops
`src/relay/windows-port-scan.ts` from both allowlists, so both offender
counts fall by one. Each ratchet pins the count from below as well as
above, so a pin left above reality fails and re-opens room for the next
direct import to land for free.

* docs(windows): qualify the no-PowerShell claim on the netstat scan

The scan starts no PowerShell of its own, but no released relay carries the
optional `windows-process-tree.node` addon (only dev-channel-win-build.yml
builds it), so the shared process-table read falls back to a CIM scan that
forks one `powershell.exe`. The EDR win is the removal of the
`-EncodedCommand` / `-ExecutionPolicy Bypass` shape, not the elimination of
PowerShell. Comment-only.

* docs(windows): record the identity-reader follow-up and the perf table's addon

attachWindowsProcessNames reads only `name`, so it should move to
`readWindowsProcessIdentityTable` once #17866 lands -- on that PR's detailed
reader it would open per-process handles for a field it discards. The reader
does not exist on this branch, so the call stays as-is with the follow-up
recorded rather than pulling #17866 in.

The process-table perf table's two Toolhelp32 rows assume the optional
`windows-process-tree.node` addon. The desktop bundles it; no released relay
does, so on an SSH host the CIM row is the operative number. Comment-only.

* docs(windows): state the CIM scan as the relay's normal path, not a fallback

No released relay carries the optional `windows-process-tree.node` addon --
release-cut.yml has zero references to it and only dev-channel-win-build.yml
builds it -- so the PowerShell CIM scan is what every SSH host runs. The
call-site docstring read as a conditional fallback standalone. Comment-only.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:12:59 -07:00
OrcaWinandOrca Worker cff202c16a fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell (#17880)
* fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell

MDE flags `-ExecutionPolicy Bypass` paired with base64 `-EncodedCommand` as a
behavioural signal. Measured on Windows 11: neither `-Command` nor
`-EncodedCommand` is execution-policy gated (both run under an explicit
`-ExecutionPolicy Restricted` and `AllSigned`; only `-File` fails), so the
switch was a pure no-op on every one of these command lines.

Removes the switch from all four sites that spelled it, and de-encodes the one
site whose payload never passes through a re-parsing shell:

- ssh-remote-powershell: one chokepoint for ~40 remote-Windows call sites.
  Base64 kept — the remote sshd DefaultShell re-parses this string.
- setup-agent-sequencing / windows-cmd-runner-delayed-launch: base64 kept —
  these strings are typed into a terminal pane.
- windows-interactive-login-spawn: base64 kept — `cmd.exe /c start` re-parses,
  and the cmd-safe-token guard rejects the `&` and `"` in the raw relay script.
- windows-mobile-firewall local runner: `-EncodedCommand` -> `-Command`, since
  execFile reaches CreateProcess with no shell in between.

The setup startup gate keeps execution-policy relief in-payload (process scope),
because it evals a user-authored startup command that may invoke a `.ps1`, and a
`.ps1` IS gated. Caught by the real-process suite; mirrors the agent-hooks
launcher's trade.

The elevated firewall child deliberately stays encoded: `Start-Process
-ArgumentList` joins its array into one ShellExecuteEx string without quoting
and PowerShell re-splits on whitespace, measured to collapse `C:\My  App\...`
to `C:\My App\...` — a firewall rule for the wrong program.

* test(ssh): enforce the no-script-file invariant remote payloads rely on

Dropping `-ExecutionPolicy Bypass` from `powerShellCommand` is a no-op only
while no remote payload loads a PowerShell script file — execution policy has
never gated anything else. That invariant held by inspection and was guarded by
nothing, so a future payload that dot-sourced, used `-File`, or imported a
`.psm1` would break only on a remote host with a Restricted/AllSigned
LocalMachine policy and no GPO: a failure on someone else's machine.

States the invariant at the wrapper, and adds a ratchet that scans every module
importing it for `.ps1`/`.psm1`, `Import-Module`, `-File`, and dot-sourcing.
The scan discovers importers itself (13 today) so new ones are covered, and
asserts it found some, so an emptied list cannot pass vacuously.

Mutation-checked: injecting each construct into a real importer fails the
matching case and names the file. The first dot-source pattern passed a
`;`-prefixed sample but missed `powerShellCommand(". '$x'")` — the likelier
shape — so the pattern now accepts a string-literal start and the self-test
samples carry their surrounding quotes.

* test(ssh): close two blind spots in the remote-payload ratchet

Both found by independent mutation testing of the ratchet itself, and both let
a real violation pass while the guard reported green.

`-File` was matched case-sensitively, so `-file $scriptVar` slipped through —
PowerShell switches are case-insensitive, and with a variable path the `.ps1`
pattern does not cover for it, so that shape escaped both nets. The naive fix
is wrong: bare /-File\b/i matches `--credential-file`, `--log-file` and
`--body-file`, which occur in three of these importers. Anchoring to a token
boundary catches the lowercase, odd-spacing and argv-element forms with zero
offenders across all 14.

Comment stripping paired a `/*` appearing inside a string (a glob such as
'src/*.ts') with any later comment close and deleted everything between, hiding
violations in the gap. Anchoring the block strip to line start, as the `//`
strip already was, fixes it — verified by injecting an `Import-Module` after a
glob string: the unanchored form misses it, the anchored form catches it.

Extends the same case-insensitivity to `.ps1`/`.psm1` and `Import-Module`,
which had the identical flaw (`import-module`, `DEPLOY.PS1` are legitimate
spellings); measured to add no false positive.

Each construct now carries the fixtures it must catch AND the near-misses it
must not, so a future tightening cannot quietly trade one for the other — the
negative fixtures are what would have caught the naive `-File` fix. Non-vacuity
bound tightened to >10 against 14 importers.

* docs(ssh): state what the remote-payload ratchet cannot see

The scan matches source text, so a script file reached only through a variable
(`& $scriptPath`) never appears in source and no pattern can catch it. The
ratchet narrows the hole; the invariant note on `powerShellCommand` covers the
remainder.

Recorded because a guard that reads as complete coverage when it is not is
worse than one that states its edge: the next author trusts it further than it
deserves, and should learn this limit from the test rather than an incident.

* test(ssh): scan remote payloads with the shared source walk

The ratchet had its own tree walk and comment stripper. The walk skipped
neither node_modules/dist/.git nor dot-directories and excluded tests by
`.test.ts` alone, so its importer count -- the guard's own goalpost -- could
be wrong about what it scanned. The stripper was anchored to line start to
dodge a `/*` inside a glob string, which silently skipped trailing comments;
`stripComments` tracks quote state and handles both.

Importer set re-derived against the shared walk: 15, floor unchanged at 10.

* fix(setup): report a failed execution-policy relief instead of swallowing it

The in-payload Set-ExecutionPolicy carried -ErrorAction SilentlyContinue and
an empty catch, so any failure vanished. A Windows PowerShell 5.1 install with
duplicate extended type data fails every cmdlet in Microsoft.PowerShell.Security
-- autoload, not policy -- and the user then saw only their own .ps1 being
refused, with no trace that the relief had been attempted or why.

-ErrorAction Stop is what routes a non-terminating failure into the catch at
all; the catch reports the FullyQualifiedErrorId to stderr and deliberately
does not rethrow, so a broken policy cmdlet cannot take down the startup this
gate exists to run. Success path is unchanged and stays stderr-clean.

Verified by execution on a clean child environment: success -> policy=Bypass,
stderr empty; shadowed failing cmdlet -> diagnostic on stderr and the gate
still continues; the old empty catch -> silent.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:12:40 -07:00
Neil 6f28e019b5 perf(hooks): use native reverse search for transcript lines (#18936) 2026-09-05 20:03:33 -07:00
Neil 388e9fb776 perf: avoid rescanning emitted source in analysis guards (#18920) 2026-09-05 20:03:14 -07:00
Neil 4204bdf717 perf: avoid repeated Quick Open exclusion string allocations (#18916) 2026-09-05 20:02:57 -07:00
Neil 926f3ff585 perf(browser): assemble fragmented tunnel frames once (#18893) 2026-09-05 20:02:30 -07:00
Neil 1c41d59203 perf(relay): drain fragmented frame buffers in linear time (#18891)
* perf(relay): drain fragmented frame buffers in linear time

* style: follow block-body lint in relay buffer checks
2026-09-05 20:02:20 -07:00
Jinwoo Hong 54a8afc91d fix(orchestration): typed error codes for dispatch and worker-start refusals (#18902)
* fix(orchestration): typed error codes for dispatch and worker-start refusals

orchestration dispatch (and worker-start, which composes it) surfaced task
not found, task not ready, and inject rejected as the same bare
runtime_error, so an agent reading the receipt could not choose between
creating the task, waiting on dependencies, or picking another terminal.

Add task_not_found (data.taskId), task_not_ready (data.status,
data.unmetDependencies), and inject_rejected (data.terminal, data.reason),
each carrying data.nextSteps so every shipped CLI already prints the
recovery. worker-start's not-ready refusal moves from task_not_startable
to task_not_ready with the same detail. runtime_error stays for genuinely
unexpected failures.

Proven red-first from RpcDispatcher through the CLI's own failure
formatting, plus an SSH bridge test that the host CLI's typed refusal
relays unchanged.

* test(orchestration): load CLI formatter at runtime in the dispatch-code test

The composite node typecheck (config/tsconfig.node.json without
--composite false, as CI runs it) rejects a static import of src/cli from
a main test with TS6307. Load the formatter and error class dynamically
behind narrow structural types, as the CLI/runtime boundary test does.

* fix(orchestration): keep task_not_startable and split the CLI-format proof

Review on #18902:

- Drop task_not_ready. worker-start already published task_not_startable
  for a not-ready Task, so renaming it would change an existing receipt
  value under old clients. dispatch now emits task_not_startable too (it was
  a bare runtime_error before, so this is purely additive), with the new
  data.status / data.unmetDependencies / data.nextSteps.
- Move the refusal receipts (code, message, data) into
  src/shared/orchestration-dispatch-refusal-contract.ts so the runtime
  emits them and the CLI test formats the identical envelope. The RPC test
  under src/main asserts toEqual against the contract; the new
  src/cli/orchestration-dispatch-refusal-format.test.ts feeds those same
  receipts to formatCliError / reportCliError. Neither tsconfig widens and
  the composite typecheck CI runs is clean.

* fix(orchestration): keep published refusal messages and type the DB claim guards

Codex review of #18902:

- Every call site keeps the exact message it published on main
  ("Task not found: <id>", "only a ready Task can start.", "cannot retry
  from Dispatch"); the shared contract now takes the message per site and
  only owns the code and data. Baseline strings are pinned as literals.
- createDispatchContext's own missing/non-ready guards, including the
  atomic-claim loser, now emit the same typed receipt instead of a bare
  Error, so a dispatch that races a status change no longer flattens to
  runtime_error. Covered by a dispatcher-level race test.
- Invalid --retry-of keeps task_not_startable but now carries status,
  unmetDependencies, retryOf, and a retry-specific next step.
- Dependency recovery text distinguishes waiting on running deps from
  retrying/unblocking failed ones.
- CLI test adds an unknown-code case so the old-client claim rests on an
  assertion, not a comment; SSH test asserts exact stdout.
- Guide table narrowed to the covered preflight cases; occupancy stays
  runtime_error and is named as such.
2026-09-05 20:27:29 -04:00
Neil abdee9ebd3 feat(automations): restore column sorting on the list (#18885)
The flat-table redesign in #16532 dropped the sort UI, orphaning
AutomationListSortHeader, nextAutomationListSort and the whole
AutomationListViewItem layer. Wire them back to the rendered list.

Name and Last run become interactive header cells again; the other six
columns stay plain text. Sorting now spans local and external rows as
one list, so the panel renders per-row components from a single sorted
collection instead of two independent sections.

Two model fixes fall out of that:

- View items key on the host-qualified row key, not the bare automation
  ID. The old builder predated automation-list-row-identity, so under
  All hosts two authorities returning the same ID collapsed in the sort
  tie-break.
- sortAutomationListViewItems takes the locale as a parameter instead of
  reading getIntlLocale(). A hidden global read is invisible to a
  dependency array, and the list result is memoized.

Keyboard traversal and focus recovery now read the sorted order, so
arrow navigation matches what is on screen. The dead unified filter is
removed in favor of the live row/entry filters the page already used.
2026-09-05 16:16:08 -07:00
Brennan BensonandMerge Sim f8780a2c86 feat(native-chat): stop monitored tasks individually (#18807)
* feat(native-chat): stop monitored tasks individually

* test: expect Claude task stop capability

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 15:35:39 -07:00
Brennan BensonandMerge Sim 2513e21390 fix(native-chat): publish structured session status from the host so the sidebar never goes stale (#18776)
* fix(native-chat): publish structured session status from the host

The sidebar learned whether a structured chat was mid-turn by replaying
the session journal in the renderer, through a reader whose lifetime was
tied to the chat pane. Hiding the pane stopped the reader before the
turn's settlement arrived, so the row stayed on "working" until the chat
was reopened. The same coupling meant a tab never opened this session
showed no status at all, and a reloaded renderer lost every settled row.

The host owns the journal, so it now projects each session's status once
per journal publication and fans the changes out on one stream per client
(`agentSession.subscribeStatus`). The projection survives eviction of an
idle session's provider child and is republished when readable sessions
are restored. The renderer bridge subscribes to that feed per runtime
target and never opens a transcript reader; the observation hook is gone.

Additive wire surface behind the existing structured capability; old
hosts reject the method and the renderer retries, showing no status.

* fix(native-chat): negotiate the status feed and stop losing a change on subscribe

The status stream is additive to a surface that already shipped, so a host
advertising agent-session.structured.v1 can still answer subscribeStatus with
method_not_found. Every renderer error path reconnected, so a remote host one
release behind got a relay round-trip every 5s and no sidebar status at all.
Give the method its own capability and probe it before subscribing; a failed
probe still retries, an absent capability does not.

Re-projecting on subscribe also wrote straight into the shared cache, so a
second client could pin the first to a stale summary. Route those diffs
through publish() before the arriving subscriber is registered.

* fix(native-chat): bound the status prompt, merge snapshots, and prove the unread path

One status frame carries every retained session and a send admits 256 KB per
prompt, so ~16 large-prompt sessions could push the snapshot past the 4 MB
outbound guard and into the retry loop. Bound latestPrompt to the same
200-char single-line preview every other agent-status row already carries.

A snapshot also replaced the cached map wholesale, so the empty first frame
from a restarting host retracted every row before restore republished them.
Merge instead; the tab map, not this feed, decides which sessions are listed.

Tests: the hidden-pane claim now sits at the host, where a journal with no
transcript subscriber is driven from running to idle; the RPC test reads a
real projection instead of its own stub.

* fix(native-chat): merge the duplicated status-event type import

* test(native-chat): pin the restart status publication, and log the unsupported host

Startup restore indexes a readable session and publishes its status, which is
what puts a never-reopened tab back in the sidebar. Only an Electron screenshot
covered that wiring; a sitting status subscriber now pins it directly.

The terminal "host too old" branch was silent, so a mixed-version report showed
an empty sidebar with nothing in the log to explain it.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 15:35:03 -07:00
Neil d7767fb196 perf(worktree): remove redundant creation and terminal startup work (#18793)
* perf(worktree): remove redundant creation and terminal startup work

* test(worktree): cover optimized creation call signatures

Preserve explicit branch adoption, WSL callback routing and sparse cleanup expectations.

* perf: preserve user Git checkout worker settings

* perf(git): skip malformed remote base probes

* perf(cli): avoid loading other agent hooks for Codex preflight

* fix(build): retain Codex preflight entry for packaged CLI

* test(ssh): wait for replacement PTY before lease recovery input

* test(ssh): verify recovered shell execution and lease ownership

* test(electron): reap isolated macOS crash reporters on teardown

* test: allow either observed self-exit snapshot ordering

* test: capture frozen-host input recovery evidence
2026-09-05 15:08:22 -07:00
Neil 51eed5a1bc feat(cli): report SSH host platforms (#18896)
* feat(cli): report SSH host platforms

* feat(cli): include SSH connection status

* fix(cli): preserve unknown SSH connection state
2026-09-05 14:35:45 -07:00
Brennan BensonandMerge Sim ddc5b75ac7 feat(native-chat): label Codex tool rows by what the command actually did (#18760)
* feat(native-chat): label Codex tool rows by what the command actually did

Codex's app-server `commandExecution` item carries `commandActions`, which
already classifies each command as a read, a search, or a directory listing
with the target path, name, or query extracted. Orca ignored the field, so
every shell call rendered as an undifferentiated row of raw argv.

Read it and name the row by its class, keeping the raw command and cwd for the
expanded view. Unclassified commands are untouched: absent, null, or malformed
`commandActions` produces byte-identical output to before.

Rank the search term above the command in the shared label keys so a classified
search row reads by what it looked for rather than the shell text that ran it.
No first-party tool input carries both keys today, so this only reaches the new
rows; an MCP tool supplying both would prefer its search term.

Note `commandActions` is the app-server spelling. `parsedCmd` is the rollout-file
shape and never arrives on this lane; a test pins that it stays ignored.

* feat(native-chat): give tool rows a category glyph beside their word

A row named only by a word makes the reader parse text to tell a read from
a search. Pair the word with an icon: icon for category, word for action,
argument for target.

Name the full eight-category vocabulary in `src/shared/native-chat-tool-icon.ts`
now — read/search/listFiles/unknown/fileChange/webSearch/mcpToolCall/
subAgentActivity — even though only the classified shell categories reach a row
today, so the MCP and web-search rows landing separately inherit these names
rather than coining their own. Glyph ids are the lucide spelling shared by
`lucide-react` and `lucide-react-native`, so mobile can resolve one name to its
own component when it adopts this; mobile rows stay text-only for now.

The glyph is decorative and `aria-hidden`: the word is the accessible name, and
never renders without it. One glyph per category, fixed across running,
completed, and failed — a row that swapped icons on completion would read as
changing identity — so the run header's active row also takes its category glyph
instead of the generic wrench it fell back to once these rows stopped being
called `shell`. A word outside the vocabulary gets the terminal glyph rather
than a blank slot, so rows stay left-aligned.

Also stand `.` in for a `listFiles` action whose `path` is null, which is what a
bare `ls` sends. The row named the action and then showed the raw argv as its
target; now it names the directory it listed.

* fix(native-chat): hold the tool run header's glyph fixed and size its slot to 16/14

The header swapped its leading glyph on settle: the active tool's icon while
running, a check once done. That is the identity swap a fixed per-category glyph
exists to prevent — the row appeared to become a different thing when it
finished. Name the header by the run's latest tool in both states and move the
completion check to the trailing edge, where the rest of the state signal already
lives.

Size both header slots to the mock's 16px slot with a 14px glyph, matching the
tool rows beneath them and the subagent summary row landing separately. They were
24/16, so the icon columns sat 8px apart and broke the left alignment the icon
treatment depends on.

The fixity test walks running, completed, and failed and pins the leading glyph
of every row by lucide's own class name, so a swap shows up as a different name
rather than a still-present icon.

* fix(codex): stop a classified shell row from asserting facts the command doesn't support

Three claims the `commandActions` row model was making on its own:

- `listFiles` with a null path was given `path: '.'`. Codex sends null for a
  recursive walk and for the repo root, and the invented path flows into
  `createToolInputDisplay().filePath`, which mobile turns into a tappable
  "open file" link onto a directory — an affordance that can only fail. The row
  now keeps the raw command, which is what the label logic already falls back to.
- A command whose actions classify as two different things (`cat a.txt && ls src`)
  was named after the first one, silently dropping the rest. Recognized actions
  must now agree on one class; a repeat of one class keeps the class and only a
  target every entry names.
- `read` lifted `name` into the journal payload, where no label ever reads it —
  `path` always wins — so it was bounded weight carrying nothing.

* fix(native-chat): give an unmodelled tool row a generic glyph, not a terminal

The row-word vocabulary named seven words, and everything else fell through to
the terminal glyph — which reads as "a shell ran here" for rows where nothing
says one did. Codex's own `apply_patch` row, `Grep`/`Glob`/`Task`/`WebFetch`/
`TodoWrite`, and every `mcp__*` tool all rendered a terminal, leaving the
declared `mcpToolCall` and `subAgentActivity` categories unreachable.

- Split the vocabulary: `unknown` stays the shell command Codex could not
  classify and keeps the terminal, while a new `other` carries the generic
  wrench that unmodelled words now fall back to.
- Read the edit family from `EDIT_TOOL_NAMES` and the command tools from
  `isCommandToolName` rather than restating either. Command tools resolve first:
  `isEditToolName` counts `shell`/`exec` as possible patch carriers, and a shell
  row is not an edit.
- Result rows get no category glyph. Their word is `translate(…, 'Result')`, so
  keying a category off it resolved a different glyph per locale; an empty slot
  keeps the rows aligned.
- The header and the row now resolve through `NativeChatToolIcon`, so one `Grep`
  run can no longer show a wrench in the header and a terminal on its line. The
  glyph map and the unused `category` prop go with the duplication.

* fix(native-chat): give the projected Diff row the file-change glyph

Every Codex fileChange item projects to a tool call named `Diff`, which the
edit set does not name — it names the tools that carry the edit in their own
input. So a run whose body renders an edited-file card was headed by the
generic wrench.

* fix(codex): stop a classified shell row offering a folder as a file to open

A listFiles action's path is a directory, and a search action's path is the
root it scanned. Lifted under `path`, both became the row's file target, which
mobile renders as a tappable open-file link that can only fail — the same dead
link the removed `{ path: '.' }` stand-in would have produced. They lift to
`directory` instead, which still labels the row but is never a file target.

* fix(mobile): keep the terminal glyph on a classified Codex shell row

Mobile's run header picks between a terminal and a generic glyph by tool
name. Now that the host publishes `read`/`search`/`list` for the same
commands it used to publish as `shell`, that name check answers false and
a command that really ran heads its run with a wrench.

Ask the shared category vocabulary instead. Mobile keeps its two icons —
porting the full glyph set is a separate lane.

* fix(native-chat): say what the run header's glyph actually guarantees

The comment claimed the header names the same tool in both states, so its
glyph cannot change on settle. It can: the live header names the running
call while the settled one names the run's last tool call, and with
out-of-order completion those differ. The glyph is fixed for whichever
tool the header names — say that, and drop the never-taken running branch
from the settled header's call.

Also pin the other half of the file-target rule: `read` keeps `path`, so
its row stays tappable, where `list`/`search` lift a folder to
`directory` and offer no target at all.

* fix(native-chat): give a rollout-transcript shell row the terminal glyph

`exec` and `local_shell` are what the Codex rollout transcript names a
shell call — `native-chat-edit-normalize` already treats those three
words as the command tools — but the activity set the glyph vocabulary
reuses carries neither, so both rows headed a real command with the
generic-tool wrench.

Named in the vocabulary rather than in that activity set, because that
set also picks the running row's copy and this is only about the glyph.

* fix(mobile): pick the run-header glyph from the call's input, not its word

Codex now names a classified shell row `read` / `search` / `list`, which
lowercase to Claude's own `Read` / `Grep` / `Glob`. Mobile has only a terminal
and a wrench, so keying that choice on the row word gave Claude's filesystem
tools a terminal for a shell that never ran.

The input separates them: Codex keeps the raw command on a classified row,
while Claude's `Read` carries only a file path. `isShellActivityToolCall`
replaces `isShellActivityToolRow` and asks the command tool names first, then
the call's input.

* fix(native-chat): give the projected diff fixture its required digest

* fix(native-chat): head a settled run with a glyph the whole run shares

The settled run header drew the glyph of the run's last tool call while the
text beside it summarizes the run's first three, so a ten-call run ending in a
`read` showed an eye above "shell npm test · shell git status · …" — a category
the summary never described.

Resolve the header's glyph from every call in the run instead: the shared
category's glyph when all agree, the generic tool glyph when the run spans
categories, and no glyph when there are no tool calls. The running header still
names the active call, whose glyph is true of it.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 14:03:45 -07:00
Neil 0bbf86bb7a fix(renderer): stop a Node-only process-table module blanking the app at boot (#18814)
Every Electron E2E spec that boots the app has been failing on
`workspaceSessionReady did not become true`, and the app itself has been
launching to a blank white window: the renderer threw
`ReferenceError: process is not defined` while evaluating a shared chunk,
so React never mounted and no startup step ever ran.

`agent-completion-poll-interval.ts` (renderer) imported one constant,
`PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS`, out of
`shared/process-table-snapshot-reader.ts` — a `node:child_process` /
`node:fs/promises` module whose dependency evaluates `process.platform` at
module scope to pick `ps` columns. The renderer runs sandboxed with
contextIsolation, where `process` is undefined, so that module-scope read
threw and took the whole chunk with it. Introduced by #18742; #18780 added a
second module-scope read next to the first.

The constant now lives in `shared/process-table-snapshot.ts`, the
environment-neutral half of the pair, and the reader re-exports it so host
callers are unchanged. The two `ps` column sets read the platform behind a
`typeof process` guard, which defuses the same landmine for any future
renderer import of that module — only hosts ever run the argv.

The regression test walks the renderer import graph (lazy routes included)
from all three entries and refuses any module that reaches a `node:` builtin.
It fails on the pre-fix import with the full 10-hop chain from `main.tsx`.
2026-09-05 02:07:37 -07:00
Neil e95d247be1 perf(terminal): cheap-tier process inspection for anchored local agent panes (#18780)
* perf(terminal): cheap-tier process inspection for anchored local agent panes

Every idle local pane's completion cadence ran a full whole-host `ps` (with
`tty=` and `command=`, 0.34-0.50s on a 1,900-process Mac, 1.15s on Linux)
purely to build `foregroundProcessEvidence` that the renderer then discards
for local ids. Add a cheap tier (same job-control columns, no tty/command,
0.03s) gated so that it introduces no user-facing trade-off:

- Only a pane whose last FULL capture proved a recognized agent may take the
  cheap tier. Panes with no anchor always take the full capture, so start
  discovery keeps today's exact behaviour.
- The cheap tick compares a per-pane fingerprint (root shell pid+start, tpgid,
  every descendant's pid+start+pgid+job-control state). Any change, a changed
  node-pty foreground name, an unreadable capture, or an incarnation mismatch
  escalates to the full capture. A recognized agent's exit is always a pid
  vanishing, which the fingerprint always sees.
- A cheap answer OMITS evidence rather than fabricating a tty-less fence.
  Remote/restore consumers never send `steadyState`, so they keep the full
  capture unchanged.
- `steadyState` is a new optional request field; an old daemon ignores it and
  answers with the full capture.

Measured (8 idle panes, 60s, idle cadence, forks counted by column set):
30 full -> 1 full + 29 cheap.

* fix(terminal): route the cheap ps capture through runProcess

The cheap-tier reader imported node:child_process directly, which the
child-process import-boundary and windowsHide ratchet tests reject (CI shards
1/8 and 3/8). Use Orca's single spawn entry point instead; it pins windowsHide
and encodes argv. Map its result onto the capture-error vocabulary:
outputTruncated -> capture_truncated, timedOut -> capture_timeout, non-zero
exit -> ps_exit_<code>. Tests mock at the runProcess seam.

* fix(perf): refuse a pane fingerprint when any descendant start marker is missing

`buildPaneProcessFingerprint` rejected only a missing root start marker; a missing descendant
marker was stamped as `?`. Two captures that both failed to read the same descendant therefore
compared equal, which removes the pid-reuse protection the fingerprint exists to provide: a
recycled pid could make a vanished agent look unchanged, and the cheap tier would keep serving
its name instead of escalating.

Reachable on Linux, where `readLinuxProcStartTime` legitimately returns null when a process
exits between the `ps` capture and the `/proc/<pid>/stat` read.

Every subtree member now needs a start marker or the fingerprint is refused, which sends the
caller to the full capture — the same conservative default every other uncertain path takes.

Reported by CodeRabbit on #18780. The two new tests fail against the previous code with
`expected '4242@2400#4300:|4300@?:4300:+' to be null`.
2026-09-05 00:57:02 -07:00
Brennan BensonandMerge Sim cc07249e78 fix(agent-session): refuse a pre-commit structured create with an envelope (#18697)
* fix(agent-session): refuse a pre-commit structured create with an envelope

The create route refused by throwing, which reaches a client as a generic
transport error indistinguishable from a lost answer — so desktop parked the
launch as visibility-unknown with no chat and no terminal. Convert the whole
pre-commit span, everything before `attach`, into a refusal envelope carrying a
code, and name the definitive-refusal allowlist the fallback decision needs.

* fix(agent-session): gate legacy fallback on definitive refusals

* fix(mobile): preserve unknown structured create outcomes

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 00:50:37 -07:00
Neil 4c5077d57a perf(persistence): skip rewriting unchanged terminal scrollback snapshots (#18764)
* perf(terminal): tighten the partial-escape-tail benchmark and equivalence test

* perf(terminal): spell the ESC gate the same way as the sibling ingest gates

* test(terminal): differential-fuzz the ESC-free partial-escape-tail gate against the unguarded fold

* test(terminal): make the escape-tail fuzz exhaustive at symbol depth, and cap the fold expectation

Two review findings on the differential fuzz, both about the test faithfully modelling the
function it guards.

The odometer generated strings by symbol depth but the caller filtered on `chunk.length`, which
is the UTF-16 code-unit count. An astral symbol is two code units, so every depth-4 string
containing one was silently skipped and the corpus was not exhaustive at depth 4 the way the
test name claimed. The generator now yields `{ depth, text }` and the caller filters on depth.
That restores the missing strings and takes the pinned corpus from 516,566 to 593,468 - exactly
the count CodeRabbit derived for the intended corpus.

The pairing assertion in the sibling suite compared the capped `advancePartialEscapeTail`
against an uncapped `extractPartialEscapeTail(pending + chunk)`. It passed only because no
pairing in that corpus crosses MAX_PARTIAL_ESCAPE_TAIL_LENGTH; it would have stopped modelling
the function the moment one did. The cap now lives in the expectation, matching the fuzz
oracle.

Re-verified the fuzz still fails on a wrong guard: mutating the gate to a bracket check fails
all four tests with a `gate diverged` assertion on a lone ESC chunk.

Reported by CodeRabbit and pullfrog on #18748.
2026-09-05 00:34:36 -07:00
Brennan BensonandMerge Sim e89deb63c9 Show Claude background task status in Native Chat (#18757)
* feat(native-chat): show Claude background task status

* fix(native-chat): carry background task fence forward

* fix(claude): bound background task stop requests

* Show running Claude background task details

* Harden Claude background task status updates

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 00:21:42 -07:00
Neil c36dd5f4c7 perf(terminal): skip the partial-escape-tail walk on ESC-free PTY chunks (#18748)
`advancePartialEscapeTail` runs once per PTY chunk, on the main thread, for every terminal —
visible, hidden or parked — inside `HeadlessEmulator`'s write path. It unconditionally
concatenated the pending tail with the whole chunk and then walked the result one code unit at
a time through a VT500 state machine.

`extractPartialEscapeTail` only leaves `ground` on an ESC byte, so with no pending tail and no
ESC in the chunk the answer is always ''. Taking that case up front skips both the full-chunk
concat and the walk. `String.prototype.includes` is a native scan, so the gate costs
essentially nothing on the chunks it does not short-circuit.

This is the same gate its two neighbours on the very same ingest path already apply —
`TerminalOscCwdTitleScanner.scan` and `TerminalMouseModeMirror.scan`, both carrying a comment
citing their measured share of a 2.2x ingest regression. This call was simply missed.

Measured by `pnpm bench:terminal-partial-escape-tail` over 640 x 16 KB chunks (10 MB), median
of 7 rounds:

| stream shape | before | after | |
| --- | --- | --- | --- |
| ESC-free (build logs, `cat`, piped output) | 40.3 ms | 0.19 ms | 213x |
| SGR-coloured output (gate does not apply) | 30.6 ms | 32.1 ms | 1.0x |

The benchmark proves equivalence over a 226-case corpus before timing, and a new unit test
pairs every pending-tail state the scanner can be left in against every chunk shape, asserting
the gate is indistinguishable from the unconditional fold.
2026-09-05 00:11:48 -07:00
Brennan BensonandMerge Sim 172aa1ac35 feat(native-chat): render agent file edits as inline diff cards (#18765)
* feat(native-chat): render agent file edits as inline diff cards

An agent's file edit rendered as a flat list of every removed line followed
by every added line, with no interleaving, no file header, and no line
numbers. A Codex edit on the transcript lane rendered no diff at all: the
patch arrives wrapped in the source string of its `exec` tool, which matched
none of the shapes the old parser looked for.

Adds one diff model shared by every edit shape the supported agents produce:

- `native-chat-edit-lcs` interleaves a snippet pair, falling back to a linear
  prefix/suffix diff above the quadratic guard.
- `native-chat-unified-patch` keeps the `@@` ranges as per-row line numbers
  instead of parsing them into display text and discarding them.
- `native-chat-begin-patch` recovers the `*** Begin Patch` envelope from the
  JavaScript string literal Codex sends it in, so that lane renders a diff.
- `native-chat-edit-normalize` folds all of it into one model, including the
  two Codex shapes that do not look like diffs: add and delete arrive as raw
  file content, and a rename is appended to the body as prose.

Claude reports an edit as a snippet pair, which cannot locate the change in
the file, so its result's resolved hunks are now carried on the tool-result
block and preferred when present. The field is optional, so an older client
reading a newer journal simply drops it. Where no resolved ranges exist the
gutter stays blank rather than showing a snippet-relative number, which would
read as a file position.

The card renders the verb from the observed change kind rather than the tool
name, pairs an edit's call and result into a single row, and takes its row and
gutter grounds from new tokens derived from the git status palette, replacing
the hardcoded Tailwind tints the old view used.

Desktop only; mobile chat keeps its existing renderer and parser untouched.

* fix(native-chat): stop the diff card from asserting an edit it cannot prove

Every defect here shares one failure mode: the card stated something the
input did not support, and stated it confidently.

Parsing:

- A hunk no longer ends on `--- `, `+++ ` or `\ No newline`. The first two
  are what a removed `-- comment` (SQL/Lua/Haskell) looks like once the
  marker is prepended, so they truncated the whole diff; the no-newline
  marker is emitted mid-hunk, between the removed old last line and the
  added new one. Real headers are recognised through `isFileHeaderPair`,
  lifted out of `native-chat-diff` so the rule has one home.
- A `*** Begin Patch` envelope with no `*** End Patch` is declined. With no
  closing marker `indexOf` returned -1 and the slice swallowed the rest of
  the command line, so `… +y" && echo ok` rendered as file content the
  agent never wrote.
- One splitter serves every shape, so a CRLF patch no longer keeps a `\r`
  on each row, in the phantom-row guard, or in the clipboard. It also
  tests for the trailing newline on the clipped body: on the un-clipped
  string that test deleted a real line whenever the slice fired.
- Truncation is carried from each slice site to the card, so content past
  the character cap can no longer render as a complete unchanged file with
  no "Diff truncated" footer.

Attribution:

- A failed or still-running edit renders no card. It kept the generic tool
  view, whose result block carries the provider's own error — the card had
  been drawing "Edited file +1 −1" from the input while hiding the red
  error body, which is worse than what preceded this feature.
- The result-as-patch fallback is scoped to `Diff`, the one tool whose call
  carries only a path. Any command tool's output could previously be read
  as a patch, so `git diff` through `exec` was reclassified as an edit of a
  file named "file" and its command line disappeared with the result.
- A whole-content write claims a creation only on evidence — the editor
  tool's own `create` command, or the provider reporting one. Overwriting a
  large existing file had always read as "Added file".
- `MultiEdit` reads its `edits[]`, and `NotebookEdit` leaves the set: it
  carries only the new cell source. Both previously fell through to the old
  renderer, so one turn could show two diff presentations at once.
- Snippet-relative numbers are dropped at the model layer rather than
  hidden by a zero-width gutter, which the flex min-width floor re-exposed
  on top of the marker and the first characters of the row.

The run memoizes its edit model, so a collapsed group no longer re-diffs on
every streaming token, and the card's copy button says what it copies.

* fix(native-chat): keep every edited file, and mark where the diff breaks

A run of hunks was concatenated into one flat row list, so the gutter jumped
from one region of the file to a distant one with nothing between them and
the reader saw two unrelated spans as one continuous block. Rows now carry
an explicit break: it holds no text and no position, counts toward neither
side of the change, is trimmed from the end where it would mark nothing, and
is left out of the copied text.

The patch envelope lost files, and lost them silently:

- An update chunk may carry no hunk header at all. The parser required one,
  returned nothing, and the caller dropped that file from a multi-file
  envelope with nothing to say it had gone. A header-less body now opens as
  a hunk of unknown position, and whether the rows are locatable is read off
  the rows themselves rather than off the header.
- The envelope's own control lines rendered as content rows in the card.
- A delete names its file and carries no body, which rendered as a card with
  an empty expandable row list. The header states the change and offers no
  disclosure behind it.
- The header patterns are anchored and `.` excludes a carriage return, so a
  CRLF envelope matched no header at all and produced no card whatsoever.
  The envelope is split on both newline forms once, up front, rather than
  each pattern having to tolerate the extra character.

A tool call's argument payload arrives as a string holding JSON. It was
passed along undecoded, which is the only reason this code carried a
hand-rolled string-literal unescaper. It is decoded once at the transcript
decoder now — defensively, since the transcript is untrusted, so anything
that is not a JSON object is left exactly as it arrived — and the unescaper
is gone. Recovering the envelope no longer guesses at argument names either:
it looks at the values, including the words of an argument vector, which is
where the envelope actually sits once the payload is decoded.

* fix(native-chat): only read a patch where a patch was actually run

Recovering the patch envelope from any value of a tool's payload meant a
write's own content was searched for one. A file documenting the patch
format rendered a card for the file its example names, while the file
actually written never appeared at all — the call and its result were
consumed by that card, so nothing was left to correct it. Two changes: the
envelope is recovered only for the tools that run one, never for a file
edit whose payload is content; and only patch- or command-bearing arguments
are searched, still including the words of an argument vector, which is
where the envelope sits when a command tool applies it.

The call payload is decoded back where it is needed rather than at the
transcript decoder. Decoding it there changed the shape every reader of a
tool's input sees, including the surface that recognises a question payload
from any tool by shape alone: a tool whose arguments happened to carry that
shape raised a question card pinned over the composer. That decode now
happens inside the envelope recovery, the one consumer that needs the
structure.

A card also states an edit as made, so it now takes evidence that it landed
— the provider reporting the call complete, or a result that is not an
error. A turn that stopped before its call was answered reported an edit
that may never have applied. This replaces the working-turn heuristic in the
view, so the rule lives in one place.

Two files still went missing. A multi-file patch has no per-file split, so
it rendered as one card under the first file's name, with the later files'
rows and their gutter numbers beneath it — a card asserting a false file
position. Patch text is now split on its file boundaries, one card per file,
each named by its own header, with a rename and a `/dev/null` side read from
the same headers. And an envelope section that names a file but carries no
body was dropped rather than reported, which is the same silent loss the
delete case was fixed for.

* fix(native-chat): type the patch-section scan and its test helper call

The section under construction was only ever assigned inside the helper that
opens one, which control-flow analysis does not see, so the variable stayed
narrowed to its initial null and reading a field off it did not compile. The
helper now only builds and records a section; the loop owns the assignment,
which also fixes a real leak in the fall-through row: it opened a section it
never made current, so the next row opened another one.

The multi-file case also passed a possibly-undefined slice to a helper that
takes an array or null.

* fix(native-chat): stop the patch lane naming files it cannot name

Splitting a patch into its files only ever looked for a boundary outside a
hunk, and nothing reopened that state once the first hunk began, so every
file after the first was swallowed as the first one's body. A `--- `/`+++ `
pair inside a hunk is now a boundary too, but only when a hunk header
follows it immediately: a removed `-- x` over an added `++ y` is never
followed by a column-0 header, which is what keeps the guard against reading
content as structure intact.

One producer cannot be recovered by any parser: it joins several files'
patches and keeps a count where the path goes, so nothing in what reaches
here names a file. That shape is refused rather than rendered under a name
no file has. Recovering the per-file paths belongs to the producer and is
filed separately.

A clipped body carries its own marker in its text, and the bound that clips
it is six times smaller than this module's, so it fires first. Read as
content, the marker became a numbered line of the file and the rows before
it were reported complete. It is recognised at the end of the text, removed,
and reported as the truncation it is — the footer says so and the copied
text no longer carries it.

Also: a move appended to the body as prose is now read as a rename on every
lane that carries the body as text, not just the one that also carries the
destination as a field, where it had been rendering as a numbered line of
the file it moved. The call's own path no longer wins over a rename's
destination, which is only ever in the header, and only sections that name a
file count toward deciding whether the call names the one file at hand. A
command that merely quotes an envelope — writing documentation about the
format — must now also invoke the tool that applies one. And two compared
directories are no longer called a rename: only a header that states both
sides as such is evidence of a move.

* fix(native-chat): anchor the move marker to its own line

The marker a producer appends to say where a file moved was matched anywhere
on the body's last line, so a row whose own content mentions a move was cut
in half at that point and the file it named claimed as the destination of a
rename that never happened. It is now anchored to the start of the final
line, on both lanes that carry the body as text.

The command that applies a patch envelope has a second spelling the runner
accepts and runs; requiring the first one refused a patch that really landed.
Both are accepted, still matched against whole argument words rather than the
payload at large.

A clipped diff also said so only under its own rows, where a collapsed card —
or one clipped down to no rows at all — showed nothing. It sits beside the
change counts now, which are visible either way.

* refactor(native-chat): tidy what the diff-card work left behind

The copy text is joined from every row of the diff, which a collapsed card
renders none of, and it was rebuilt on every render to seed a prop. It is
memoized on the rows, matching how the run memoizes its edit model.

The two scanners that read patch text kept the same file-section alternation
verbatim, so they could drift apart while both looking correct; there is one
definition now, beside the header-pair rule that already lives there.

Also: the row that marks a break between regions is built in one place, so it
is no longer exported; the move destination in the envelope reader was a
function-wide binding written and read within one iteration, which read as if
a move carried between sections; and a test comment named the wrong mechanism
for keeping a card collapsed.

Adds the missing pin on what the copy affordance actually copies.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-04 23:42:43 -07:00
Neil 149732df6d perf(persistence): stop the session write re-scanning and rebuilding unchanged state (#18739) 2026-09-04 23:39:02 -07:00
Brennan BensonandMerge Sim b0c67eaf88 feat(mobile): port the restructured native-chat turn status and live tool progress (#18761)
* feat(mobile): port the restructured native-chat turn status and live tool progress

Mobile chat had a single static "Agent is working" row and no live tool
activity, while the desktop restructure (#17597, #18705) replaced that with a
per-turn status row and a running-tool label. This brings mobile to parity and
puts the derivation in one place instead of two.

Shared (new, pure, RN-safe — desktop uses them as i18n fallbacks, mobile
directly, matching the native-chat-empty-state pattern):
- `native-chat-turn-status.ts`: duration formatting, label selection, the
  turn-timing state machine, and the active/settled split.
- `native-chat-tool-activity.ts`: command-tool classification, the running-tool
  label descriptor, and running-call selection.

Desktop now consumes both; `NativeChatWorkingStatus`, `NativeChatToolRun` and
`use-native-chat-turn-status` keep their existing behavior and strings.

Mobile gains the "Thinking" / "Working for 12s" / "Worked for 3m 4s" row with a
caret that discloses the turn's tool activity, the pulsing "Running npm test"
row with terminal-vs-wrench glyphs, and desktop's rule that a completed turn's
tool run hides behind the turn caret. The bridge lane is untouched and keeps its
three-dot indicator. Headings, quotes, code, lists and table cells are now
selectable.

Files at their max-lines cap were split rather than bumped: the tool-run subtree,
the prompt card, the session-lane wiring, and the turn-disclosure state each move
to their own module.

* perf(mobile): stop the turn-status rows from re-rendering the whole transcript

A streaming turn re-renders the chat list many times a second. The disclosure
wiring handed every row a fresh status object and a fresh toggle closure on each
of those renders, so `MobileNativeChatMessage`'s memo never held and every
visible row re-rendered per tick — including settled turns that had not changed.

Memoize the status selection on the timing map, and keep one stable toggle
handler per turn (pruned when a turn leaves the transcript) attached only to the
settled rows that can actually disclose anything. Now only the live turn's row
changes identity while the agent works.

* fix(mobile): keep the turn clock running when the optimistic echo is replaced

An accepted send renders as `pending-N` until the transcript echo lands under
its real message id. That flips the active turn key mid-turn, and the timing
reducer treated the new key as a new turn — so a turn that had reached
"Working for 8s" visibly restarted at "Working for 0s".

The reducer now carries the start over when the previous key names a turn that
has since left the transcript, which is exactly the echo-replacement case. A
genuinely new turn (the previous key still in the transcript) and a turn that had
already settled both keep their own clock; both are pinned by tests. Desktop does
not pass the new key and is unaffected.

* fix(mobile): keep the Tools toggle working on settled turns

Hiding a settled turn's tool run behind the turn caret (desktop parity) also
made the composer's global Tools control a no-op on every completed turn: the
run it wanted to expand was not rendered at all. Let that toggle override the
hiding, so it still reveals every run at once the way it did before.

* fix(mobile): re-key the turn timing instead of only carrying its start

The previous fix carried the start forward only while the turn was still
working. When the transcript echo landed after the turn had already settled,
the new key inherited nothing, the settled timing was pruned with the old key,
and the turn's "Worked for N" row disappeared entirely.

Move the timing onto the new key instead, which covers both orderings: an
in-flight turn keeps counting from its original start (and later settles against
it), and an already-settled turn keeps its duration. Both orderings are pinned.

* test(mobile): pin the structured turn-status wiring at the view level

Emulator QA could not reach the structured lane (mobile's Create Tab -> Codex
falls back to a terminal tab when agentSession.createSupport says unsupported),
so the view's own lane wiring had no coverage — the one seam between the shared
turn-timing reducer and the rendered rows.

Assert what the view hands each row: the live user turn gets a status object and
the three-dot indicator is gone on the structured lane; the bridge lane keeps the
indicator and gets no status; a finished turn settles to a numeric duration with
a toggle; and an assistant row never carries a status row of its own.

* fix(mobile): isolate structured chat turn state

* fix(mobile): let the capability RPC actually store what a phone advertises

`runtime.clientCapabilities.update` records the advertised set by assigning
`authenticatedSocket.clientCapabilities`, but the socket handed to the dispatcher
defined that property with a getter only. In strict mode the assignment throws
`TypeError: Cannot set property clientCapabilities ... which has only a getter`,
so the RPC answered `runtime_error` and the set was never stored.

The consequence is not subtle: `supportsStructuredAgentSessions` requires the
capability, so `projectSessionTabAgentStatus` removed every `agent-session` tab
from a phone that had advertised it correctly. A paired phone saw ZERO tabs on a
worktree whose only tab was a structured Codex chat — structured native chat was
unreachable on mobile over this transport, not just missing its new turn UI.

Give the socket a setter that writes through to the channel, which already owns
the set for the connection's lifetime, so later requests on the same socket see
it. Found while trying to capture emulator screenshots of the turn-status port:
two full QA runs reported the new UI "missing" because the phone could only ever
get a bridge/PTY tab.

* fix(mobile): carry the turn key instead of caching a handler in a ref

Builds on the scope-isolation fix: that kept (and extended) a ref that is
written during render — once to memoize a per-turn handler, once to prune dead
turns, once to reset on a scope change. React Doctor's "Ref mutated during
render" is what CI's `check:react-doctor:changed` was failing on (x2), and on
mobile it is a real hazard rather than a style note: react-freeze discards
renders, and a discarded render would leave the cache mutated.

Pass the settled turn's key down the row instead and let it call one stable
handler with it. That preserves both properties the cache was bought for — per
scope isolation, and identity stability so a streaming transcript does not
defeat the row's memo — with no ref writes and no pruning to get wrong. The
scope-keyed expanded set and the 128-turn cap are untouched; their tests move to
the new contract and one now pins handler identity across a re-render.

Note for future changes here: `check:code-quality:changed` does NOT cover this.
CI additionally runs the standalone react-doctor CLI, which has rules the oxlint
plugin config does not enable.

* fix: ship native chat status translations

* test(native-chat): pin the shared copy against the English catalog

The shared constants are desktop's i18n fallback and mobile's actually-rendered
string. If one changes without the other, desktop keeps rendering en.json while
mobile renders the constant — and nothing fails, because a fallback is only used
when the key is missing. That silent divergence is the exact thing the shared
module exists to prevent, and it is now reachable precisely because these strings
are runtime-required rather than statically extracted.

Assert every key in both shared copy objects matches en.json byte for byte, plus
the interpolation placeholders the catalog interpolates on.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-04 23:31:39 -07:00
Neil 3f84c358f0 fix: recover from an alternate shell install, and close the relay duplicate-echo gap (#18796)
* fix: recover from an alternate shell install, and close the relay echo gap

#18768: a startup profile that `exec`s a second install of the same shell keeps
the pid but loses the wrapper's ready marker, and the recovery probe rejected the
replacement's different canonical path -- costing plain Codex the full 15s
barrier. The probe now also accepts an install that the pane's own PATH resolves,
so a binary merely named bash/zsh outside it stays rejected.

#18767: the SSH relay left plain Codex on early startup delivery, displaying the
launch twice under a slow profile. The shell is the host's to know, so the relay
now folds it into the same rule the daemon uses, and the SSH background client
waits for the marker on any Codex launch. Bracketed paste is now gated on an
observed marker rather than on the intent to wait, so a fallback release on a
host shell that never publishes one submits raw.

The non-daemon local provider needs no change: it hands Codex to the wrapper's
own prompt hook and never writes it into the PTY.

* review: correct an overclaiming comment, announce a silent skip, drop a shim

Readiness review of #18796. The delivery comment claimed waiting "costs nothing",
which is only true on a host that arms the marker -- fish, sh, Windows and hosts
predating #18767 release on the fallback instead. Say so.

The alternate-install recovery tests skip on usrmerge hosts, where /usr/bin/bash
resolves back to /bin/bash; announce that rather than reading as coverage that
does not exist.

Import the line-editor predicate from shared directly instead of through a
re-export left on daemon/shell-ready.
2026-09-04 23:27:30 -07:00
Brennan BensonandMerge Sim cb7f7dd11a fix(native-chat): tell old mobile builds why a structured chat is missing (#18756)
* fix(native-chat): tell old mobile builds why a structured chat is missing

A structured native chat started on desktop was simply absent on a paired phone
running any shipped App Store build. The host strips every `agent-session` tab
from a client that does not advertise `agent-session.structured.v1`, and no
released mobile build advertises it — so the chat had no representation at all
and no way to explain itself.

Keep the row and retitle it instead of deleting it. The shipped client does not
filter unknown tab types and renders whatever title the host sends, so an old
build now shows the chat's slot with a title naming the fix. Nothing is removed,
so the tab order, groups and layout it belonged to are left intact.

The prompt is keyed on the capability for that specific agent, not on the
combined policy boolean: a capable phone whose desktop simply has the experiment
off would otherwise be told to take an update that cannot help it. Claude rows
are prompted too — mobile cannot render them yet and a later build can, so the
message is true for that client as well.

Restore is no longer gated on the caller's capability. It stayed gated on the
host setting, which is what decides whether there is anything to reach at all,
but gating on capability left an old client with nothing to project after a
desktop restart: neither the chat nor the prompt.

Tab titles are capped at 128px on one line in every shipped build, so the string
is sized for ~15 characters rather than a sentence.

Prompted rows are visible rows, so the host now permits all five session-tab
mutations on them, close included. That is intended: a mobile close runs the same
teardown as the desktop's own Close button.

* fix(native-chat): keep fallback tabs safe and truthful

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-04 20:08:39 -07:00
Brennan BensonandMerge Sim 5a1acfec17 fix(native-chat): make document paths and links clickable in chat (#18712)
* fix(chat): make assistant file paths clickable

* fix(chat): tighten native file link handling

* fix(chat): link prose-joined relative paths separately

* fix(chat): preserve complete Unicode file links

* fix(chat): preserve links before sentence punctuation

* test(chat): align structured session link props

* fix(native-chat): harden generated file links

* fix(native-chat): reject reference-number false positives

* test(native-chat): align structured session parity

* perf(native-chat): bound file-link detection

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-04 20:03:51 -07:00
Brennan BensonandMerge Sim 8096cb2803 fix(native-chat): render Claude structured chat through the same UI as Codex (#18743)
* fix(native-chat): render Claude structured chat through the same UI as Codex

Structured native chat is one shared, agent-agnostic component tree, but three
Codex-hardcoded terms on that path made a Claude session render differently.

- `showTurnStatus` was `agent === 'codex'`, which gated the whole structured
  presentation: the live tool-progress row, the completion check and activity
  grouping (`structuredActivityUi`), and the Thinking / Working for N /
  Worked for N status rows. Claude fell back to the legacy compact chrome.
- `runtimeContext` was likewise Codex-only, so a Claude transcript rendered
  images as filename chips instead of previews.
- The composer's structured slash menu always served the Codex catalog,
  ignoring `agent`. That disagreed with the dispatcher, which does branch per
  agent: Codex-only tokens offered to Claude missed the command guard and were
  sent to the model as literal prompt text, where Codex shows an error.

The first two gates landed Codex-first (#17597, #18266) before the Claude
structured lane existed; they were rollout scoping, not capability limits.
Neither `useNativeChatTurnStatus` nor `useNativeChatImageRuntimeContext` has
any agent-specific logic. The menu now reads `structuredSlashCommands(agent)`,
the function the dispatcher already used, so both read one list.

No new rendering logic: two gates removed and one existing shared function
reused. There are now zero `agent === 'codex' | 'claude'` branches in any
native-chat component.

* docs(native-chat): update structured turn status contract

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-04 17:40:20 -07:00
Neil 0a821e5bc8 fix(crash-reporting): make the own-Chromium gate a real choke point, and stop a refusal leaking the root (#18459)
* fix(crash-reporting): make the own-Chromium gate a real choke point

Round-3 review found the guard was not the choke point its own comments
claimed: six pid-addressed `taskkill /pid <pid> /t /f` families in main were
ungated and uninstrumented, so the stale-pid shape stayed producible and a
`selfInitiatedTreeKillCount: 0` could read as exculpatory when it was not.

- Gate the remaining main-process families: the git command-runner abort, the
  notebook-cell and automation-precheck timeouts.
- Turn the `src/shared` seam into the gate itself (`process-tree-kill-gate`), so
  the runProcess choke point, the codex app-server deadline kill and the
  ephemeral-VM recipe kill ask the same decision. Those three are compiled into
  the CLI/relay too and cannot import main; main installs the guard at preflight.
- Ratchet (`main-process-tree-kill-gate.test.ts`): a new pid-addressed taskkill
  in main that skips the gate fails, and the allowlist entries must still exist.
- Give pid-addressed kills eviction priority in the 32-entry ring: 32 routine
  `win-pty-job` teardowns from a window-close burst no longer evict the one
  entry that discriminates a self-kill from an external one.
- Correct the coverage doc, which described the uninstrumented Windows sites as
  POSIX `process.kill(-pid)` group kills and omitted the git and codex paths.

* fix(crash-reporting): keep a refused tree-kill from leaking the root it owns

A refusal must block the pid-addressed tree walk, not the termination. Five of
the six gated sites returned on refusal with no fallback, so a refused
`taskkill /pid /t /f` left git.exe, a timed-out notebook cell, an automation
precheck or an ephemeral-VM recipe running while the caller reported it stopped.
The root kill is addressed by the child handle, which cannot reach the recycled
pid the refusal is about, so it stays correct and required on that path.

Also fixes the ring eviction the scope preference introduced: with the ring
saturated by pid-addressed kills, the only non-pid-addressed entry is the one
just pushed, so the splice evicted itself and the detail came back `{}` --
byte-identical to the external-kill arm, in the window-close case the guard
exists for. Eviction now excludes the newest entry and falls back to FIFO.

Tests: refusal now asserts the root kill at all six sites, and the ring covers
the saturated-pid ordering as well as round 3's group-burst ordering.

* fix(crash-reporting): stop a refused tree-kill leaking the commit-message agent, and count call sites

Two round-5 blocking findings, both open on main and on both branches.

`killSourceControlAgentProcess` had no root-kill fallback on its win32 arm: the
taskkill was the only termination, so once the own-Chromium gate could refuse it
the promise resolved having killed nothing. Both callers do
`terminationComplete ??= killSourceControlAgentProcess(child)` and then release
the managed-home lock on that promise, so a refusal left the local Codex/Claude
commit-message agent running while the caller reported it stopped -- the
lock-contention failure the taskkill was added for. Same fix as the six sibling
sites: the handle-addressed root kill cannot reach the recycled pid the refusal
is about, so it stays correct and required on that path.

The ratchet was file-granular, not call-site granular: one gate mention anywhere
in a file exempted every taskkill in it, which left the six files that now ask
the gate ratchet-blind -- the inverse of what it is for. It now counts `/pid`
call sites against gate admissions per file, so a second ungated kill inside an
existing family fails. Keying on the `/pid` argument rather than a quoted
`taskkill` also catches a kill whose program name comes from a constant. The
three comments that claimed more than the old scan enforced now state the rule
and its two remaining blind spots.

Also: the recording in `admitSelfInitiatedTreeKill` is now wrapped the way the
`admitProcessTreeKill` seam already wraps it, with the refusal decision taken
before anything that can throw so a diagnostics failure cannot flip it; and
`orca-chromium-process-pids` documents the false-positive direction (a stale
`getAppMetrics()` entry plus pid reuse refuses a live unrelated child), which is
the mechanism the root-kill fallback exists to bound.

Tests: refusal now asserts the root kill at all seven sites; the ratchet asserts
call-site counting and the constant-program form.

* test(crash-reporting): run the own-Chromium gate against real Windows trees

Nothing on this branch had ever executed on Windows. The unit tests pin the
gate's decision against a mocked taskkill, which cannot show that the decision
does anything to a real process: that `/T /F` reaps a detached grandchild, that
a refusal leaves that tree standing, or that the handle-addressed root kill the
refusal path falls back to reaps the root while orphaning descendants.

Adds a win32-gated live test covering all four, registered in both the
`package_windows` CI lane and `WINDOWS_PACKAGE_TESTS` as
`win32-test-lane-registration` requires.

Also completes the coverage doc's "never instrumented" list, which omitted the
macOS keyboard-input-source probe's POSIX group kill in `ipc/app.ts`.

* fix(crash-reporting): pin the commit-message root kill on the Windows arm

The first Windows run of this branch found nine failures the macOS suite
cannot see: `commit-message-text-generation-test-harness` asserts
`expect(child.kill).not.toHaveBeenCalled()` on `process.platform === 'win32'`,
which is the contract the previous commit deliberately replaced — and it
branches on the real platform, so it is dead code everywhere CI runs today.

The harness now asserts the handle-addressed root kill on every platform. On
win32 it lands after the tree walk, so the expectation waits rather than reading
one tick early, and its ten call sites await it. Red against the pre-fix arm at
all seven sites; the production code is unchanged.

* test(crash-reporting): remove the Windows lane marker tree through the retrying helper

The new win32 spec teardown used a raw rmSync, which the windows-lane-tree-removal
boundary ratchet rejects — and which is exactly the EPERM the ratchet exists to
prevent, since this spec's marker directory is written by processes it has just
force-killed.

* fix(crash-reporting): only refuse pid-addressed tree walks, disclose the handle-less codex site

The own-Chromium gate refused the POSIX process-group arm of
signalProcessTree as well, which was new macOS/Linux behaviour: a stale
getAppMetrics() entry plus pid reuse would orphan a group that main reaps
today. A POSIX group only holds what Orca put in it, so the refusal is now
scoped to win-taskkill-tree and the POSIX arm is recorded and admitted like
the other group kills in main. That also drops the synchronous
getAppMetrics() read from every POSIX termination.

codex-turn-added-roots kills roots found by a table walk, so a refusal has
no handle to fall back to. Pin that the refusal is visible - crumb written,
turn reported as not cancelled - rather than fixing what cannot be fixed.

* test(crash-reporting): detach the Windows survival fixture and observe real spawns
2026-09-04 16:42:42 -07:00
a65332a8bd feat(claude): move structured native chat onto the Claude Agent SDK and enable it on macOS and Linux (#18560)
* Join structured attach teardown through journal bind

* fix: restore structured chat parity

* feat: add Claude structured session adapter

* fix: harden Claude structured adapter

* fix: close Claude adapter edge cases

* fix: start Claude init deadline after launch

* feat: wire Claude structured sessions

* fix: harden Claude structured runtime

* fix: fence Claude structured compatibility

* fix: preserve Claude free-text prompt answers

* fix: decode addressed Claude prompt text

* feat: enable Claude structured chat on mobile

* fix(mobile): keep structured chat provider-aware

* fix(mobile): negotiate Claude structured tabs

* fix: keep scoped RPC tests native-free

* fix: secure mobile structured image delivery

* fix: close structured session data-loss gaps

* fix: prove real Claude structured startup

* fix: consume pre-spawn proof before retry

* feat(native-chat): add desktop structured sessions

* fix(native-chat): satisfy structured session cleanup gates

* fix(native-chat): keep structured renders pure

* fix(native-chat): open composer pickers upward

* fix(native-chat): use existing view for structured sessions

* fix: harden structured desktop status projection

* fix: close structured desktop lifecycle gaps

* fix: fence structured AI Vault resumes

* fix: fence structured AI Vault resumes

* fix: preserve structured tabs during activation

* feat: toggle structured sessions between chat and TUI

* fix: harden structured session handoffs

* fix: bind structured TUI before rollout proof

* fix: complete structured chat round trips

* fix: align structured TUI return readiness

* fix(native-chat): make reverse handoff transactional

* Add Claude structured TUI handoff seams

* fix(native-chat): clear sticky handoff recovery

* fix(native-chat): complete mobile reverse after TUI exit

* fix(native-chat): keep TUI transcripts readable

* fix(native-chat): recover TUI transcript gaps

* fix(native-chat): recover claimed TUI owners

* fix(native-chat): retain cold TUI proof authority

* fix(native-chat): preserve Claude handoff authority

* fix(native-chat): recover TUI transcripts read-only

* fix(native-chat): harden Claude handoff recovery

* fix(native-chat): serialize structured handoff recovery

* fix(native-chat): close handoff admission races

* fix(native-chat): validate pinned launch environment

* fix(native-chat): revalidate restored and retried owners

* fix(native-chat): gate restart recovery publications

* fix(i18n): catalog Claude session controls

* fix(native-chat): wait for structured TUI process proof

* fix(native-chat): queue stale idle TUI handoffs

* fix(native-chat): route structured Codex options directly

* fix(native-chat): persist structured session options

* fix(native-chat): hydrate resumed structured options

* fix(native-chat): preserve options across structured handoffs

* fix(native-chat): replay pending option mutations

* fix(native-chat): rotate settled handoff operations

* fix(native-chat): rotate refused send operations

* test(native-chat): derive refusal retry state from host

* test(native-chat): give the host-oracle matrix test an explicit timeout

* fix(native-chat): keep Claude option controls idle

* fix mobile structured first-send hydration race

* fix(native-chat): preserve handoff launch authority

* fix(native-chat): harden shared handoff recovery

* fix(native-chat): serialize structured handoff recovery

* fix(native-chat): close handoff admission races

* fix(native-chat): validate pinned launch environment

* fix(native-chat): revalidate restored and retried owners

* fix(native-chat): gate restart recovery publications

* fix(i18n): catalog structured session recovery control

* fix(native-chat): wait for structured TUI process proof

* fix(native-chat): queue stale idle TUI handoffs

* fix(native-chat): keep structured recovery provider-neutral

* fix(native-chat): drop local terminal topology from structured sync

* fix structured outbox and tab restore races

* fix(native-chat): preserve Claude question groups

* fix structured provider visibility and request handling

* fix structured session TUI handoff recovery

* fix reverse structured session handoff

* fix(native-chat): recover Claude outbox and resume state

* chore(mobile): preserve the working-tree lockfile state before the main merge

Carries the pre-existing uncommitted mobile/pnpm-lock.yaml modification into history so the
main merge cannot overwrite it. Verified benign pnpm drift (babel 7.29.7->7.29.8 transitives
plus deprecation metadata); drops no patchedDependencies (the mobile lockfile declares none).

* test(native-chat): drop orphaned Claude handoff-auth test left by the main merge

'pins Claude handoff auth through the terminal provider boundary' is absent from main and its
production counterpart preserveClaudeAuthEnv no longer exists outside this test - orphaned residue
of the terminal/native handoff work this PR excludes by scope.

Removed rather than repaired: the failure was a renamed field (providerHome -> providerRoot), and
renaming it would have carried out-of-scope handoff code into the merge. Body preserved as evidence
and logged in CLAUDE-STRUCTURED-DISPOSITION-TABLE.md.

* Fix mobile structured turn state

* fix Claude structured session blockers

* fix claude structured lane blockers

* fix Claude acquisition exit proof

* fix(claude): route stream-json launch through process wrapper

* fix(claude): gate structured chat support

* Fix Claude structured launch gating

* fix(claude): split session acquisition and prune mobile scope

* test(claude): align structured session fixtures

* fix(agent-session): preserve handoff launch arguments

* fix(claude): open journals through the factory after origin/main split

The journal opener moved to journal-store-factory on main; retarget the
Claude structured tests that still imported the old path.

* fix(claude): resolve Claude structured launch args, auth, and win32 proof

The origin/main merge re-expressed the lane's Claude wiring onto main's split
orca-runtime facade and dropped three wires past green typecheck and lint.

- resolveLaunchArgs discarded its provider parameter, so structured Claude
  sessions were launched with Codex app-server flags; Claude exits on
  --dangerously-bypass-approvals-and-sandbox, and a Codex arg-parse throw
  could block Claude session creation outright.
- resolveClaudeLaunchEnv was no longer supplied, so the launch resolver fell
  back to the whole process env as configuredEnv and
  buildClaudeChildProcessEnv re-applied every auth var it had just stripped.
  The resolver now merges the Claude overlay onto a strip-applied copy of the
  inherited env, which also keeps PATH intact for withCliRuntimeOnPath.
- The windowsProcessStartTimeAvailable producer was gone while the contract
  field and both consumers survived, so the renderer gate fail-closed and
  structured native chat was unreachable on every win32 host.

Separately, structured Claude pinned CLAUDE_CONFIG_DIR unconditionally. An
explicit pin makes the CLI abandon the macOS Keychain even when it names the
CLI's own default, so a default claude.ai account could not authenticate where
the legacy Claude terminal could. Pin only a home the CLI would not resolve on
its own, matching ClaudeRuntimePathResolver, and compare against the env the
child would otherwise inherit so a diverging overlay cannot outrank the
record's account home.

Also await the now-async revealNativeSession in its regression test, and set
the native status before revealing so a rejecting reveal cannot leave a
session released but never marked native.

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

* fix(claude): scrub case-insensitive Windows auth env

* fix(native-chat): settle handoff outcome-write failures instead of leaking them

A store write failure while recording a handoff outcome escaped the flow
runner's catch handler, so the client never received the failure and the
flow surfaced as an unhandled rejection (seen as an intermittent
agent_session_store_corrupt error in the proven-dead-retry suite, whose
teardown raced the flow's trailing outcome write). Record the failed
outcome best-effort, and drain the coordinator before that test's
teardown removes the store root.

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

* fix(native-chat): make the structured close-failure toast provider-neutral

The structuredSessionCloseFailed toast fires for any structured session,
but its copy said 'Codex chat', so a Claude structured session that fails
to close showed the wrong provider name. The launch-failure toast is only
reachable behind the agent === 'codex' gate, so its copy stays as is.

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

* fix(native-chat): wire structured handoff proof recovery

* fix(native-chat): wire structured handoff proof recovery

* fix(native-chat): correct the structured chat opt-in copy

The one `experimentalStructuredNativeChat` toggle gates both providers —
`useStructuredAgentSessionCreate` runs `canUseStructuredNativeChat` for
`'claude'` as well as `'codex'` — but its description named only Codex.

Its scope line also said Windows keeps using terminal chat, while the gate
refuses win32 only until the host proves it can read a process start time.
`structured-native-chat-availability.test.ts` already pins that Windows is
allowed once the proof is cached, so the two contradicted each other.

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

* test(claude): pin @anthropic-ai/claude-agent-sdk 0.3.251 contracts against a scripted CLI

PR 1 of the SDK migration: dependency + test-only harness, no product wiring.

- Pin @anthropic-ai/claude-agent-sdk to exactly 0.3.251 — not the newest
  release — because 0.3.251 (published 2026-08-28) clears the repo's 3-day
  minimumReleaseAge supply-chain gate with no exclusion, while the newest
  release was minutes old and would have required excluding a brand-new
  publish from the exact control built to catch brand-new malicious
  publishes. Every contract this design depends on was verified identical
  on 0.3.251: the full option surface, no pid on SpawnedProcess (custom
  spawner stays mandatory), env defaulting to process.env when omitted, and
  --replay-user-messages appearing only via extraArgs.
- Exclude all eight bundled CLI platform binaries via
  ignoredOptionalDependencies. The setting lives in pnpm-workspace.yaml
  because pnpm 12 no longer reads the package.json "pnpm" field (it warns
  and ignores it; verified by install ablation). Excluding the binaries is
  what makes Orca's pathToClaudeCodeExecutable override mandatory rather
  than merely preferred. Note: pnpm 12.0.0 honors the ignore list when
  reconciling an existing lockfile but not on fresh resolution of a new
  dependency, so the lockfile's SDK entry was pinned surgically; both
  'pnpm install' and 'pnpm install --frozen-lockfile' verify clean and
  stable against the committed lockfile.
- Contract-pin suite drives the real SDK against a scripted fake CLI and pins:
  unknown type/field/content-block pass-through (and keep_alive interception),
  spawner env fidelity plus the omitted-env process.env inheritance sharp edge,
  extraArgs producing --replay-user-messages, argument parity for every
  CLAUDE_STRUCTURED_BASE_ARGS entry plus --session-id/--resume/
  --resume-session-at, canUseTool wire request_id stability and abort on
  control_cancel_request, one spawn per query, pathToClaudeCodeExecutable
  honored by the default spawner, the exact SDK version, and the eight platform
  binaries staying uninstalled.

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

* feat(claude): drive the structured transport through the agent SDK

Replaces the hand-rolled `claude -p --input-format stream-json` transport with
@anthropic-ai/claude-agent-sdk 0.3.251, keeping the existing connection
interface for this commit so the acquisition path changes minimally. The
control-plane rewrite is a separate change.

Orca still supplies the process. `spawnClaudeCodeProcess` routes through
`spawnProcess`, retains the child and its pid — the triple the durable lease
adjudicates on — drains stderr so exit errors keep their tail, and hands `.cmd`
shims to Orca's Windows argument encoder rather than the SDK's plain spawn.
`close()` keeps Orca's own bounded tree-kill and exit deadline, so it still
resolves true only after an observed exit.

Launch resolution emits an SDK options object instead of argv; durable
`launchArgs` translate to a typed option where one exists and to `extraArgs`
otherwise, refusing a token neither can carry rather than dropping it. The
child env is always passed explicitly — omitting it would let the SDK inherit
`process.env` and reintroduce the ambient `ANTHROPIC_*` leak. The stdout line
parser is deleted; the SDK owns framing, and unknown frames still reach the
translator verbatim.

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

* fix(claude): settle the frame the SDK pulled but never wrote

The SDK's input pump is `for await (frame of prompt) { await transport.write(frame) }`.
When that write rejects — the child dies between Orca's liveness guard and the
write — the for-await ends abruptly and calls the generator's `return()`, so the
code after `yield` never runs. The frame was already shift()ed out of `queued`,
so the later `fail()` from the exit path could not reach it and `send()` never
settled: `dispatchClaudeTurn` awaits that send before it can return `unknown`,
wedging the caller and the durable outbox. The pre-SDK transport rejected on the
stdin write callback instead.

Retain the in-flight entry and settle it from the generator's cleanup, and let
fail() reach it too for the pump that never resumes at all.

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

* fix(claude): keep the agent SDK behind the structured-Claude boundary

The ordinary OrcaRuntimeService graph statically reaches the Claude adapter and
so the transport module, whose first line imported @anthropic-ai/claude-agent-sdk.
The SDK is evaluated whenever the regular runtime loads, before any structured
Claude session is chosen: it sets process.env.NoDefaultCurrentDirectoryInExePath,
changing Windows executable resolution for later subprocesses, and a missing or
incompatible install would break normal runtime startup — for a user who never
leaves the terminal/TUI path.

Defer the SDK to the connection, memoized so it loads once per process, and add
the import-graph ratchet: a walk from the Electron main entry that fails on any
static import of the package, plus a clean-fork check that loading the runtime
leaves the Windows search variable untouched and a child-process pin that the
side effect is still real.

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

* fix(claude): answer list_models so the picker stops serving the seed

sendControlRequest had no list_models case, so every request hit the default
reject; readClaudeStructuredSessionOptions swallows that with .catch(() => null)
and falls back to the static catalog. Every structured session therefore served a
hardcoded model list with no per-model effort levels, no resolvedModel and no
default detection, and nothing surfaced the failure. The pre-SDK transport got the
live catalog from the CLI.

Route it through the SDK's supportedModels(), wrapped in the { models } envelope
the existing parser reads.

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

* fix(claude): reap the child's descendants before killing it

The forced step of the exit ladder went through the Codex helper, which spawns
`pkill -KILL -P <pid>` and SIGKILLs the parent in the same tick: the parent
usually dies first, the descendants reparent to pid 1, and `-P` matches nothing.
An MCP or launcher descendant of a stubborn Claude child was left running. The
test named for that requirement declined to assert it and killed the survivor by
hand instead, so it could not fail for the thing it was named after.

Route the Claude reap through Orca's existing sweep, which snapshots descendants
while their parent link still exists and signals them before the root goes, and
on Windows uses the identity-gated `taskkill /T /F`. The test now asserts the
descendant is dead; the manual kill stays only as a failure-safe. close() still
returns true only on an observed exit.

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

* fix(native-chat): merge the duplicated handoff type import

CI's static-analysis lint (`oxlint --config
config/oxlint-code-quality-native-plugins.json src config tests mobile
--deny-warnings`) exits 1 on the two separate `import type` statements from the
same module.

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

* fix(claude): answer a permission callback whose signal already aborted

settleFrom registered the abort listener and then delivered the request. A
callback that arrives already aborted never fires that event, so the promise
stayed pending behind a durable prompt with no cancel path. Check the signal
first, emit the cancel, and resolve the SDK's null sentinel without registering.

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

* test(claude): wait for the child to record the frame, not just for its report

The scripted CLI writes its report at startup, so `until(readReport)` returned a
report with no user messages whenever the child had not yet read the line. The
assertion then failed under parallel load. Poll for the frame instead of for the
file.

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

* fix(claude): coalesce partial deltas onto one assistant item and stop painting result frames

Under --include-partial-messages every stream_event frame carries its own
uuid, and the final assistant frame for a block carries yet another; only
message.id ties them. The translator keyed each delta by its frame uuid, so a
reply painted as one bubble per delta chunk followed by a complete duplicate
under the final frame's uuid. The block's first stream frame now mints the
claude:(sessionId, uuid) identity, deltas coalesce onto it through the shared
60ms seam, and the final frame reconciles onto that same item.

Known SDK bookkeeping no longer reaches the provider-fallback row: result
subtypes are catalogued and settled by the turn lifecycle, an empty thinking
block (redacted thinking) is a modeled kind, a string-content user replay is a
text block, and an empty user frame paints nothing. An unmodeled result
subtype or content kind still lands on the bounded fallback row.

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

* fix(claude): prove descendant exit at the close boundary instead of on an unref'd timer

close() reported proven=true as soon as the direct child exited while the
descendant sweep's SIGKILL sat on an unref'd 2 s timer, so a SIGTERM-resistant
MCP server outlived the lease release. The reaper now composes the same shared
primitives the Codex structured provider uses: snapshot, verified bounded
descendant termination on POSIX, taskkill /T /F on Windows. The proof is false
whenever descendants outlive the deadline, a retried close re-verifies the
retained snapshot rather than trusting the dead root, and the raw pipe child no
longer goes through the PTY job sweep it never owned a job for.

Measured on macOS: a killed child of a SIGSTOPped parent stays a matching zombie
row in ps, so the root is killed while verification runs rather than stopped
first as the Codex non-group path does.

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

* feat(claude): replace the hand-rolled control plane with the SDK's native surface

PR 3 of the Claude structured SDK migration removes the wire-frame scaffolding
PR 2 kept, so Orca drives the SDK's typed control surface directly.

Inbound permissions move from a rebuilt control_request dispatch to the SDK's
canUseTool / onUserDialog callbacks. The prompt registry now carries the
callback's own resolver: a decodable can_use_tool becomes a durable prompt whose
answer settles the callback; a malformed one is denied without registering; the
SDK's abort signal (fired on control_cancel_request, which the SDK matches and
dedups itself) forgets the prompt and settles it null, and a late answer after
abort finds no prompt and is refused. Closing settles every in-flight callback so
no promise dangles. The claude-agent-sdk-control-bridge that rebuilt the wire
frame is deleted.

Outbound control maps to Query methods: interrupt() for cancel, setModel /
setPermissionMode / applyFlagSettings for options, supportedModels for the model
list, initializationResult() for init proof, each under Orca's own request
deadline and error classification. Cancel is interrupt-receipt aware: a CLI
advertising interrupt_cancel_queued_v1 gets cancel_queued in one round trip,
otherwise the receipt's still_queued uuids are swept with cancel_async_message so
a cancelled turn cannot spawn a later unexpected turn; older CLIs resolve no
receipt. Init keeps the 10s deadline and the unauthenticated-startup guidance.

Every behavior is failing-first and ablation-proven; the toggle-off import
boundary and the accepted loss of unknown-control visibility rows are unchanged.

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

* fix(claude): arm the descendant snapshot before stdin closes and make the tree verdict unproven by default

A healthy Claude root leaves within the graceful window, and the close ladder
only snapshotted descendants when the root was still alive after that window.
So the common close never looked at the tree: `treeExited` stayed null,
`!== false` passed it, and close() reported a proven exit with an MCP child
still running. A root that died before the walk made the snapshot vacuous too.

The proof is now unproven by default. The reaper holds one verdict in Orca's
vocabulary (exited / live / unverifiable), assigned in exactly one place from
the bounded verification, and close() returns true only on `exited`. The
snapshot is armed before stdin closes, while the root can still be walked, and
is verified after the root exits; a root that left before any snapshot could
be armed stays unverifiable rather than vouching for descendants it never
showed us. The shared verifier gains the three-way verdict behind its boolean
face, and the connection reports the root and tree verdicts separately along
with the child's exit status.

One verification per close attempt: the retried close re-verifies, so the
intra-attempt re-reap is gone from the teardown budget.

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

* fix(claude): verify the Windows tree after taskkill instead of trusting that it ran

`terminateWindowsProcessTree` resolves from taskkill's callback whatever the
error says, so a timeout, an access denial, a recycled root and a surviving
descendant all looked identical to the reaper — which then returned a proven
exit unconditionally. close() reported true and the lease was released with an
MCP descendant potentially still live.

The Windows branch now snapshots the root's descendants while it is alive and,
after taskkill, polls a fresh process table to a bounded deadline: a row still
matching by pid AND creation time is `live`, an unreadable table is
`unverifiable`, and only a table with no match is `exited`. Creation time is
the PID-reuse guard the POSIX path gets from ps lstart, so a descendant that
denied a creation-time query is omitted rather than signalled on a bare pid.
A root already observed exited is never taskkilled: `/T /F` on a recycled pid
would take an unrelated tree down with it.

The captured tree is tagged by platform so neither verifier can be handed the
other's rows.

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

* fix(claude): release a reservation on a first-hand root exit instead of latching it into manual recovery

Making close() strict about the descendant tree exposed a second defect at the
same boundary. A create-time acquisition has no ownerProcess until publication,
so an unproven cleanup mapped to handoffStage `manual-recovery`, and
adjudication then refuses every later attach with agent_session_ownership_unknown.
A user who was merely signed out, or whose --resume the CLI rejected, wedged the
session id permanently.

Each question now answers from its own evidence. close() is unchanged and stays
strict about the tree. Separately, the lease is keyed on the root's pid and
start time, so when Orca's own child handle observed that root exit and no
descendant snapshot was ever admissible, the reservation is released and the
CLI's exit code and stderr reach the user. A descendant observed still alive,
or a root Orca never saw leave, stays unproven and keeps the reservation.

The settlement records only what was observed: the released lease says the
provider process exited and its descendants were not verifiable, rather than
reusing the wording that claims cleanup proved no child remains.

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

* fix(claude): surface an API error a result frame reports instead of settling the turn on it

The SDK models an API failure as a SUCCESS-subtype result whose `result` string
is the user-facing error text, with no assistant frame behind it. The translator
suppressed every catalogued result subtype as turn bookkeeping, so that turn
tombstoned its lifecycle and showed the user a completed, empty reply with no
sign anything had failed.

Suppression is now by meaning. A result reporting a failure routes to the
bounded provider-error surface, leading with the provider's own sentence and
keeping the raw frame behind the row's disclosure; ordinary successful results
stay off the timeline as before. A turn the user aborted also stays suppressed:
its interrupt frame already says so, and its execution diagnostic would only be
noise on every stop.

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

* fix(claude): drop the stream state of turns that never received their final frame

Every streamed delta recorded its block's identity, latest text and checkpoint
length. Only the final assistant frame removed them, so an interrupted turn left
its whole accumulated reply reachable until the session was disposed, and a long
session with repeated interruptions grew those maps without bound. The partial
text was already journaled by the flush that precedes settlement, so the live
copy was pure retention.

That state now lives in its own module, named for what it does — grow a streamed
block's journal row between its deltas and its final frame — and turn settlement
drops every block still awaiting a final. The translator reports how many remain,
which is the invariant: a settled turn leaves none.

Also makes a timed-out process-table read retryable while the root is still
alive. A loaded host can miss the table's one-second deadline, and latching that
as "no descendants" both lost the descendant sweep and, on a busy machine, made
the close ladder report unproven for a tree it never actually looked at. Only
the root's death still makes a missing snapshot final.

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

* perf(claude): capture the Windows descendant tree from one process-table read

The capture walked the descendant tree and then read the table again for the
creation times the walk's projection drops. Each read is bounded in seconds and
both run inside the close ladder's budget, so the second one cost the worst-case
teardown three seconds for data the first read already held.

The walk is now exported from the module that owns it and runs over rows the
caller has already read, which is also what lets the snapshot keep the
PID-reuse guard the projection cannot carry.

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

* fix(pty): spend the descendant verification window instead of surrendering on one slow table read

The verification abandoned the whole check the first time a process-table read
missed its own one-second deadline, with seconds of its window still unspent.
On a loaded host that reported a tree unverifiable without ever having looked at
it, which the Claude close ladder then turned into an unproven close and a
retried teardown. It also made the descendant-exit tests flake under a parallel
suite run, for the same reason and with the same honest-but-premature verdict.

A read that missed its deadline is now simply not an answer: the loop waits and
reads again until its own deadline, and only a window that ends without a
readable table reports unverifiable. This can only turn a premature verdict into
one backed by evidence; it never manufactures a proof.

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

* fix(claude): never let a later failed look collapse an observed live descendant into unverifiable

The reaper's single assignment site latched only 'exited', so a second reap
whose table reads all missed their deadline overwrote an earlier completed
verification's 'live' with 'unverifiable'. The acquisition release gate
discriminates on exactly that pair, so a root exit after such a decay released
the lease over a descendant that had been observed alive. The latch is now
monotone in trust order: exited is final, and live is only ever raised to exited.

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

* fix(claude): never prove a Windows tree gone while a descendant denied identification

The Windows snapshot dropped rows that denied the creation-time query, and an
emptied snapshot was judged exited without any table read: a descendant Orca was
refused information about was treated as one that had left. The snapshot now
counts the unidentified rows it saw, and verification caps its verdict at
unverifiable while any exist. Nothing is ever signalled on a bare pid, as before.

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

* fix(claude): classify cleanup after a first-hand exit as a root exit instead of a proven tree

When the CLI died between a successful acquire and the host's commit or proof
of the lease, handleExit had already removed the session, so releaseAcquisition
found nothing and reported true. The attach flow then settled exit-proven with
deathEvidence claiming cleanup proved no provider child remains, though the
tree was never verified. The adapter now keeps the exit that removed a
published session until the session is acquired again; acquisition cleanup runs
that connection's close ladder and classifies its verdict exactly as a
start-time failure would be, so the record reads root-exit-observed. The wire
helper keeps that typed classification and its provider diagnostic instead of
wrapping it as unproven, and the router gives up its owner even when the
release throws.

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

* fix(claude): integrate SDK teardown and picker lifecycle fixes

* fix(claude): preserve resume leaf and settle processless spawns

* fix(claude): reacquire from persisted resume leaf

* fix(native-chat): restore Claude grouped question handling

* fix(claude): persist only resumable transcript leaves

* fix(claude): recover structured session exits safely

* fix(claude): close remaining structured session P1s

* fix(claude): harden transcript branch proof

* Remove superseded root fix reports

* fix(windows): restore indexed descendant row walk

* fix(router): forward force-close lifecycle

* fix(claude): fence stale turn cancellations

* fix(claude): fence cancellation after unknown dispatch

* fix(claude): fence replay and option recovery races

* fix(claude): block replay fallback after waiter eviction

* fix(claude): fence evicted slash results

* fix(claude): fence ambiguous results and restore options safely

* fix(claude): scrub SDK child env and localize pending launch

* fix(claude): pin transcript roots and exit recovery proofs

* fix(claude): retain unproven SDK exits

* fix(claude): settle retained exit before reacquire

* fix(claude): resume from settled retained cursor

* chore: remove tracked review artifact

* fix: harden Claude SDK transport session cleanup

* fix: close Claude sessions safely

* fix(claude): close races with fresh child snapshots

* fix(claude): fail closed on recycled child identities

* fix(claude): gate root cleanup on process identity

* fix(claude): fence same-second root identity reuse

* fix(claude): restore the root SIGKILL fallback the identity gate took away

The direct root kill goes through the handle Node owns, not through a pid:
libuv drops that handle in the same turn it reaps, so the signal either
reaches the process Orca spawned or reaches nothing at all. Gating it on a
process-table probe therefore bought no safety and cost the tree its only
fallback whenever the probe declined -- a first capture landing in the fork's
own second, a recycled descendant pid voiding the snapshot, or a process table
that could not be read on either platform.

Identity verification stays where a bare pid is genuinely addressed: Windows
`taskkill /T /F`, and the descendant sweep's own revalidation before it signals.

Also stops a declined root probe from collapsing an observed `live` or `exited`
descendant verdict into `unverifiable`, and stops a successful taskkill from
reporting `unverifiable` because a later probe found the root correctly dead.

* docs(claude): rewrap the root-kill ordering comment

* Match the Claude structured launch to the terminal path's managed-account auth rules

The SDK path stripped ambient Anthropic auth unconditionally, let an explicit
agentDefaultEnv override beat a pinned managed account, and had no account-switch
guard. Reuse the terminal preflight's own predicate and messages so both transports
strip, refuse, and report identically, and cover the CLI transcript location that
mobile native chat depends on.

* Reach the Claude structured chat lane from the desktop UI

The main process has had a complete, correctly gated Claude Agent SDK lane for
a while, but no renderer ever asked for it: the launch route accepted only
`codex`, and the create path was typed `agent: 'codex'` end to end.

Widen both to the structured provider union that already exists
(`AgentSessionHandleProvider`), and generalize the codex-named create path
instead of adding a Claude twin beside it. The pending-launch registry is now
keyed by agent as well as workspace — a shared key handed a second caller the
first agent's intent, so a Claude and a Codex launch in one worktree collided.

Windows, per agent. Codex's client-side win32 refusal is deliberate and settled
elsewhere, so it stays exactly as it was. Claude's answer is no longer guessed
from the client's platform: a structured session fences its provider child on
that child's process start time, and only the executing host knows whether it
can read one. `agentSession.createSupport` already answers precisely that, per
agent, and had no renderer caller — so the Claude create path asks it before
creating and turns a "no", or a probe it cannot get answered, into the
definitive refusal the launch fallback already handles. Fail closed either way.

That refusal mapping also closes a real gap: the host reports an unsupported
location by throwing `structured_agent_session_unsupported`, which reaches the
client as a transport rejection rather than a refusal envelope, so
`StructuredAgentSessionCreateRefusalError` never fired. The launch would retry
the create, strand itself in `visibilityUnknown`, run no legacy fallback, and
show an error toast.

Close a fail-open hole while Claude and win32 become reachable: `create` with a
client-supplied location, and `ensure`, both skip the worktree-resolving support
check. They now ask the executing host the same question directly, so a host
that cannot fence a provider child no longer creates one on a client's say-so.

Also deletes `structured-agent-session-provider-routing.ts`, a duplicate of
`structured-agent-session-provider-support.ts` with no importers.

WSL, SSH and paired hosts, floating workspaces, draft prompt delivery, explicit
TUI customization and initial session options all keep refusing; folder
workspaces keep working.

* P1-1: make the structured Claude auth policy required and testable

The optional dep plus a {stripAuthEnv:false} fallback meant a dropped wiring
under-stripped silently. Required at all three hops, asserted at install time for
the @ts-nocheck caller, and the settings-to-policy mapping is now a named tested
function.

* P2-3: mobile's default Claude transcript root must follow CLAUDE_CONFIG_DIR

session-file-resolver's default ignored the variable the pinned account home
follows, so a CLAUDE_CONFIG_DIR launch wrote one tree and mobile read another. The
Task-4 test now resolves with no root override (mobile's own call) and checks the
answer against the root the CLI itself reports, instead of mirroring the code under
test's own expression.

* P2-1/P2-2/P3: close the teardown window, join the live-auth gate, align the refusal

P2-1: a switch beginning inside the acquire teardown left a dead chat and no
replacement. Past that point the launch waits the swap out and refuses only if it
never settles; the entry guard still refuses outright, because nothing is torn down
there yet.
P2-2: structured children now hold the same OAuth-refresh gate a Claude PTY does,
so a managed refresh cannot rotate the token out from under a live turn.
P3: the refusal now matches the strip it guards (case-folded on win32, presence not
truthiness), and the dead structured-to-TUI builder states its auth policy instead
of silently signing a system-auth user out.

* Make the live-auth gate tests independent of sibling connection teardown order

* Do not offer structured Claude under a WSL-only managed account

Structured Claude launches against the ambient Claude config, which the account
service keeps in sync with the selected HOST account. A WSL-bound managed
account lives inside the distro and is never synced there, so on Windows a
structured session would authenticate as whatever the ambient identity happens
to be while the UI names the WSL account — the user is told one identity and
given another.

That was unreachable only because nothing offered structured Claude on win32.
Enabling it makes it reachable, so gate it here rather than patching the auth
layer: refuse the structured path when the active managed Claude account is
WSL-bound, and let the terminal-backed path — which resolves the account per
runtime — handle that account shape.

The answer rides the agentSession.createSupport seam the renderer already
consumes, so no new capability and no renderer knowledge of account internals.
A create the host declines becomes the definitive refusal the launch fallback
already turns into a legacy native chat tab, with no error toast.

Unknown answers refuse. An install with no managed accounts claims no identity
and is fine, but an active selection that cannot be resolved — or account state
that cannot be read at all — is not evidence that the ambient identity is right.

Claude only. Codex resolves its account through a different path and its
createSupport answer is untouched, as is every Codex routing decision.

* Read the structured Claude account gate through the auth policy's accessor

The gate resolved the active account from the account-service snapshot's
runtime map; the auth policy resolves it with
getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }). Those are
two sources and two resolution rules, and they disagree on a legacy settings
blob that carries the selection only in the flat activeClaudeManagedAccountId:
the accessor falls through to it, a direct read of the runtime map does not. The
gate would then refuse a launch the policy would have run under host-1 — and in
the mirror case a session could be admitted under a policy computed from a
different account than the gate approved.

Read the same settings through the same accessor so agreement is structural
rather than coincidental, and drop the controller accessor that existed only to
reach the snapshot.

No behaviour change for any state both already agreed on; Codex is untouched.

* Round-3 review fixes: N-1 empty-value regression, N-2 gate leak window, N-4 lost history

N-1: my presence-based conflict predicate refused a terminal launch that works
today. 'ANTHROPIC_API_KEY=' is how a user blanks a variable and the settings
pipeline preserves that empty value; an empty override cannot beat the pinned
account and the strip removes the name anyway. Back to truthiness for the value,
keeping the win32 case folding.
N-2: enter the live-auth gate only after the exit/close handlers that release it,
so no throw in between can leave an entry nothing reconciles.
N-4: the Claude transcript resolver searches config-dir-then-default and de-dupes,
matching the Codex sibling in the same file, so adopting CLAUDE_CONFIG_DIR no
longer hides history written before it.

* Run the managed-account gate on every Claude acquisition, not just create

createSupport gates the create path, but a session's account state can change
while it lives. A reacquire after an unexpected child exit re-resolves the
launch and re-derives auth, with nothing re-checking the gate — so a session
created while supported could come back up in the refused shape. With the strip
predicate keyed on there being an active non-WSL account, the WSL-only user's
normalized steady state (accounts exist, none active) does not strip, and that
reacquire reaches the child with ambient auth while the UI names the account.

Gate at resolveLaunch, the one choke point every acquisition passes through,
refusing with the pre-spawn error the caller already handles. Same predicate as
create-time, now sharing one settings reader so the two cannot drift.

Claude only; Codex resolves its account on a different path and is untouched.

The runtime class that wires this does not typecheck its own `this` calls — a
missing hookup compiles clean — so the wiring is pinned behaviourally rather
than trusted to the compiler.

* Move the structured Claude gate out of the @ts-nocheck runtime files

Both call sites of the managed-account gate sat in files whose first line is
`// @ts-nocheck`, so neither was typechecked: three arguments to a one-argument
function plus an undeclared identifier compiled clean. New auth-identity
decision logic had no compiler behind it.

Move the verdict into a checked module that takes the two facts the runtime
owns — the adapter's answer and a settings getter — and decides. The runtime
class now only forwards. Move the gate reader's construction into the checked
installer too, so the nocheck file passes a plain settings closure and never
names a gate symbol.

Every reference to the gate predicate and its reader now lives in a checked
file, so the ablation that used to pass silently is a compile error at both the
create-support and reacquire sites.

Removing the file-level @ts-nocheck is a separate, larger job and is not
attempted here.

* Derive the gate test's auth policy from the settings under test

A hardcoded stripAuthEnv asserts a gate/policy pairing production cannot
produce, and false additionally lets launch.env inherit the runner's real
process.env. Derive via claudeStructuredAuthPolicyForSettings instead: the
gate settings type is the same Pick the policy takes, and both resolve the
account through getSelectedClaudeAccountIdForTarget.

* Pin the absent-vs-empty distinction in the managed-account gate

An empty claudeManagedAccounts array is a real answer: the user has no managed
accounts, nothing claims an identity, and the ambient path is legitimate. A
readable settings object with no such field is settings we failed to parse —
the same unknown as unreadable — so it refuses.

The two are one character apart in the code and the difference is invisible
without the reasoning, so record it at the branch and pin both sides. The test
fails under the obvious "consistency fix" of treating a missing field as empty.

* fix(claude): keep command queue bookkeeping out of the transcript

Claude Code 2.1.258 emits a `command_lifecycle` frame for every uuid-stamped
command it starts, completes or cancels. The frame carries a command uuid and a
state and no content, and the CLI keeps it out of its own transcript -- but it
is absent from the SDK's SDKMessage union and so from Orca's frame catalogue,
where an uncatalogued kind defaults to a substantive row. Every structured turn
therefore painted raw JSON rows into the user-visible transcript.

Catalogue it and disposition it as status chrome. The unknown-kind default stays
`timeline-substantive`: a kind we have never seen is likelier to carry content
than to be chrome, and a visible row we can catalogue later beats content we
silently dropped. A lifecycle state that reads as a failure still surfaces,
because the payload error check in `classifyProviderFrame` outranks the
catalogue.

* fix(claude): let a re-walked descendant become eligible for the forced sweep

A descendant first observed by a capture inside its own birth second could never
be SIGKILLed: `ps lstart` is second-resolution, so that capture cannot rule out
a pid recycled later in the same second, and the merge pinned each retained row
to the boundary of the walk that first saw it. SIGTERM-resistant children forked
in that window were signalled and then never escalated -- they survived close,
quit and restart, reparented to init, and had to be killed by hand.

Advancing that boundary on any later capture would be unsound: a later capture
matching pid, pgid and start-second is exactly what an impostor would also show.
But a capture is not a match -- it is a fresh ppid walk from a root Node pins
through its own handle, so a row it re-derives is proved ours at that instant
without appealing to its start time. Chain the fence from there instead, and
take that walk at the close boundary while the root certainly still lives: the
root may leave inside the grace window, and the post-timeout refresh never runs.

A row absent from the later walk still keeps its earlier boundary, and a row no
walk has ever re-derived in a later second is still never escalated.

* Treat an absent managed-account list as empty, not as unreadable

An empty claudeManagedAccounts array and a missing one are the same answer:
this user has no managed Claude accounts, so nothing claims an identity and
ambient auth is the truth. Refusing on absence strands any profile that simply
never wrote the key, and it disagrees with the auth policy, whose own predicate
takes `(accounts ?? [])` for exactly this reason.

Only settings that cannot be READ stay unknown, and those still refuse — as do
a WSL-bound active account and a selection naming an account the list does not
explain.

The earlier reasoning treated a missing field as settings we failed to parse.
That conflated "not present" with "not readable"; only the second is unknown.

* Support structured Claude when accounts are registered but none is selected

Registered-but-deselected Claude accounts were refused, which is behaviourally
identical to having no accounts at all: the auth policy does not strip, ambient
auth is the truth, and the UI names no host identity. A user who deselected
their accounts silently got legacy chat with nothing explaining why.

Nothing selected for the host runtime is two states the settings cannot tell
apart after the fact, because pruneInvalidClaudeRuntimeSelection empties the
host slot and persists null in the second one:

  honest deselection      -> ambient auth, UI names nothing   -> SUPPORTED
  the WSL-only steady state -> ambient auth, UI names the WSL account -> REFUSED

The presence of any WSL-bound account in the list decides. Simplifying this to
"none active -> supported" re-opens the auth-identity misrepresentation, so the
tests fail loudly on exactly that: five of them, across the unit rule and the
createSupport path.

* Stop treating an unanswerable create-support probe as a refusal

A worktree is not resolvable for a beat after createWorktree resolves, so a
probe fired immediately after creation fails the RPC with selector_not_found
instead of answering. The catch collapsed that into `supported = false`, so the
composer refused and quietly built a terminal session — the gate never said no,
it was never asked successfully. Elapsed time was the only input that decided
whether a Claude launch went structured.

"Could not answer" and "answered no" are different states and only the second
is a verdict. Retry while the host cannot yet resolve the selector, with a
bounded backoff that covers the measured window with margin, and keep refusing
on the first ask for everything else. Fail-closed is unchanged: a probe that
still cannot be answered when the budget is spent refuses.

The retry is narrowed with the shared error-code matcher, which classifies a
token that transports re-wrap into a longer message without matching prose that
merely mentions it.

Codex never probes, so this race has never been able to refuse a Codex launch —
the race itself is identical for it. Recorded at the early return, because
whoever gives Codex a probe inherits the bug.

* fix(claude): fence the forced sweep on re-derivation, not on lstart's second

A descendant forked in the same wall-clock second as every walk that sees it was
signalled with SIGTERM and then never escalated, so a SIGTERM-resistant child
survived tab close, app quit and a full relaunch. Two children of one parent
96ms apart across a second boundary took opposite paths. The leak predates this
branch: it reproduces with the change reverted.

`ps lstart` has one-second resolution, so a walk landing inside a row's birth
second can never rule out a pid recycled later in that same second. But a walk
is not a match: a ppid walk only reaches what the root actually parents, and the
root is pinned by Node's own handle, so a row the walk re-derived is ours
whatever second it was born in -- a stranger would have to have been forked into
our tree, and then it is not a stranger. Fence the escalation on that.

Rows a merge retained from an earlier walk are not re-derived and still answer
to the start-time fence, which remains correct for them.

Scoped to callers that revalidate identity before signalling, which is the
Claude close path. Codex teardown reaches this same verifier and is unchanged;
the argument holds there too, but widening it is its own deliberate change.

Also reverts two changes from the previous attempt at this leak. Advancing the
capture boundary on a later walk is inert once the sweep fences on re-derivation
-- both key on the same set of rows, so the new term short-circuits for exactly
the rows whose boundary it advanced. The extra ladder refresh was a duplicate
full process-table read: close() already awaits tree.refresh() immediately
before proveClaudeChildExit, on the only path that reaches it.

Known property: the kill lands roughly a grace window after the walk that proved
membership, so a pid recycled inside that gap could in principle be signalled.
It is bounded -- matchingSnapshotRows already requires the live row to carry the
same start-second and pgid, so an impostor must be born in the remainder of that
one second, land on that exact pid, and sit in the same process group, and it
has already received the unfenced SIGTERM from the same loop.

* Run the Claude structured integration suite as a runtime client

The suite exercises agentSession.* for Claude, not the mobile surface: nothing
in it asserts anything mobile-specific and its sibling integration suites use
'runtime'. Mobile now additionally requires the experimental structured-chat
setting, which structured-agent-session.test.ts pins in both states, so the
stale 'mobile' fixture was claiming coverage it never had.

* fix(claude): report effort from get_settings, which is the only frame that has it

The composer's Effort pill rendered blank in every structured session. This is
not a missing source: the publication reads `effortLevel` off the `system/init`
frame, and that frame has never carried an effort of any kind, while the correct
value is already fetched at acquisition and thrown away on the auth diagnostic.
Verified two ways -- a live get_settings probe against Claude Code 2.1.258, and
the shipped binary's own init frame construction, which lists `model` and no
effort. So `reportedOptions.effort` was always empty, the options reader dropped
the key, and the pill had no value. Model survived only because
`currentModelId()` has a fallback chain.

The get_settings call acquisition already makes reports the session's current
effort as `effective.effortLevel`; pass that into the publication instead.
Selecting an effort already worked, so this is the arrival value only.

The legacy PTY path is unaffected and must not be "fixed" to match: it reads its
effort by parsing the startup banner (`CLAUDE_MODEL_EFFORT` in
src/renderer/src/components/native-chat/claude-terminal-session-options.ts),
which is why it shows a value where the structured path does not.

Also removes the fixture that hid this: the fake init frame invented
`effortLevel: 'high'`, a field the CLI does not send, which is why every gate
stayed green over a value that is always empty in production. The fixture's
get_settings now returns the real {applied, effective, sources} shape instead of
a bare `{env: {}}`, so the two adapter tests that asserted an effort keep
asserting it through the path production actually uses.

The reader returns null rather than defaulting: an effort nothing measured would
repeat the fixture's mistake, and a blank pill is the honest degradation if the
provider ever renames the key.

* fix(claude): only record an effort the child confirms it adopted

apply_flag_settings answers `success` for an effort it then ignores. Measured
against Claude Code 2.1.258: applying `bogus-effort-xyz` returns
subtype "success" with no error while `applied.effort` stays at its previous
value, and a valid `low` moves it. The option write treated the absence of a
throw as adoption and recorded the requested value unconditionally, so Orca
would show and persist an effort the child was not using, with nothing anywhere
reporting a problem.

Read the effort back after applying it, through the same reader the arrival
value uses, and reject when the child reports a different one. A readback that
could not be taken is not evidence of a refusal -- the apply itself succeeded --
so it still records; only a readback that disagrees rejects.

Not reachable from today's picker, which offers catalog values only, but the
CLI's effort catalog is server-delivered and has changed before, so a retired id
would otherwise become a pill confidently displaying a setting that never took.

* test(claude): assert the effort contract against the real binary

The blank pill survived every gate because the only tests that touched it were
fixture-backed, and the fixture invented the field. A test that pins the shape
we read cannot catch the provider renaming the key, which is the failure mode
that produced this defect.

Asserts both halves against a live authenticated CLI: that no frame it publishes
carries an effort at all, and that the session's current effort arrives through
get_settings. Which frame proves the session varies by host -- this machine
proves it with a SessionStart hook rather than a system/init frame -- so the
negative half asserts over every published frame rather than picking one.

Skips with the rest of the file when no authenticated CLI is present.

* fix(claude): stop the synthesised content-part kinds leaking into the transcript

Sending an image put a bare `claude · message:user:content:image` row between
the user's bubble and the answer. Two causes, and only the second is a family.

An image part counted as modelled only when `source.type === 'url'`, but
claudeDispatchMessageContent sends a local attachment as a base64 source and the
CLI replays that shape back, so every attached image was classified unmodelled.
Accept the base64 and file sources Orca itself sends.

The family is the real defect. `message:<role>:content:<type>` kinds are
synthesised at runtime from whatever `part.type` arrives, so unlike the
top-level frame catalogue they can never be enumerated ahead of time -- the
`?? 'timeline-substantive'` default then prints the synthesised name at a user
who cannot act on it. That default is right for top-level frames, where
"substantive" means show the frame; here it meant show our own vocabulary, which
drops the content AND leaks the opcode.

So an unrenderable part now renders a sentence saying exactly that, with the
kind and payload still on the row's disclosure. A part that carries its own
readable sentence keeps it -- the placeholder is a fallback, not an override.

An unknown future part type is therefore visible, never silently dropped and
never printed as a kind: the same principle as the effort readback, which
records only what the provider confirms.

* Declare agentSession.requestHandoff on the cross-version wire surface

The manifest is a ratchet for cross-version reachability, so the method is
declared with real HandoffParams rather than counted. requestHandoff is
capability-gated through requireStructuredHost and has no client caller, so
declaring it is the whole of the change.

Also model two host capabilities the harness omitted: the stub host's
supportsCreate, and the fake adapter's, without which adapterSupportsCreate
falls through to a supportsLocation the fake also lacks. Every ensure was
refused for the harness's silence rather than for its location.

* Gate structured Claude session tabs on the client capability that names them

The Claude structured lane deleted the projection's `agent !== 'codex'`
filter and added CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY in the
same commit, but never wired the constant to anything. Paired clients then
received agent-session tabs for Claude, which no shipped client renders --
mobile's resolveMobileNativeChat returns null for every agent but codex, so
the row listed and selected into a pane with neither chat nor terminal.

Restore the filter behind the declared capability instead of the bare agent
name. No client advertises it yet, so this matches main's behaviour today
and becomes a negotiation a future client can opt into.

* Confirm the structured Claude model against the model the CLI reports

set_model answers success for any string, including a model it cannot
resolve — the failure only surfaces when the turn runs — and get_settings
reports the settings-file model, not the session's. The init frame that
opens each turn is the only channel carrying the adopted model, so keep
the session's reported model current from it instead of reading it once
at acquisition.

Also stop rejecting an effort the readback cannot represent: max is
session-scoped and excluded from the persisted effortLevel, so a readback
reporting the level underneath it is an absence of evidence, not a refusal.

* Clear the session-option hedge when the provider confirms the value

The pill claimed every option was unconfirmed for the life of the session:
the renderer recorded each write as dispatched and nothing ever moved it,
so a model the CLI had already reported back still read as unconfirmed.

Carry the provider's own confirmation to the surface. Main reports which
option ids the provider named rather than merely accepted, and the client
re-reads options as a turn changes, because the frame that opens a turn is
where the adopted model arrives. A value the provider has not reported
stays hedged, including an effort whose readback could not be taken.

The confirmed list is optional on the wire: a host that predates it sends
nothing and the client keeps hedging, which is the behaviour it had.

* Keep the model report current across an acquisition fence bump

* Show the picked session-option value and let the provider report correct it

The pill showed a "not confirmed" second tooltip line for any value we had sent
but not yet seen reported back. Nothing acts on it, and for the PTY lane it was
permanent — that transport has no report channel. The pill now shows the picked
value immediately and the provider's per-turn report corrects it when the two
disagree; a newer local write still outranks a report that precedes it.

`dispatched` stays as a provenance member rather than collapsing into `applied`:
it is produced independently by the PTY lane, and it is where the `confirmed`
wire field lands, which would otherwise be unobservable.

Effort keeps its readback and its rejection path. That matters more now, not
less: with the hedge gone the rejection is the only user-visible failure signal
on this surface, so a spurious one would be the loudest bug here. Skipping the
readback for an effort the settings response structurally cannot echo is what
prevents it — the response carries the persisted level, so reading it back for a
session-scoped value would report the level underneath and fail a valid write.

* Hedge a session-option value only when the terminal transport sent it

Both lanes emit `dispatched`, so it could never say which one produced a value.
The descriptor now carries the transport that built it, set once in the shared
snapshot builder from a parameter that is required rather than defaulted — the
builder is the only place a descriptor is constructed, so a new producer has to
name its lane or fail to compile.

The structured lane confirms every value from the provider's own per-turn report,
which makes the hedge transient noise there. The terminal lane can only learn an
outcome by parsing the screen back, and only for Claude: every other agent's
`dispatched` value stays unconfirmed for the life of the session, so the line is
the only signal that we sent something we never saw land.

* Refuse an effort the session's model advertises no control for

* Refuse tab mutations on a Claude row the client never negotiated

The branch added a case asserting a client advertising only
agent-session.structured.v1 may mutate a claude row. That is the same
ungated behaviour the projection gate removes, encoded a second time —
mutation authorization reads the projection, so hiding the row refuses
the write. Assert that contract instead, and add the positive case for a
client that does negotiate Claude rows.

* Resolve the Claude session's current model in one place so the effort guard and the pill agree

* Record an effort the child did not adopt instead of refusing the write

apply_flag_settings answers success for an effort it then ignores, so the
readback exists to detect that. Refusing on it made the detection a veto,
and a veto is only correct if the readback can never be wrong about which
model is current -- which it was, twice. The pre-flight guard already
refuses a level the model advertises no control for, so the veto guarded a
door that is now locked upstream.

Keep the detection, drop the refusal: a disagreement records the child's
own answer and omits the option from confirmed, so main stops vouching for
a value the provider rejected without blocking the user's write.

* Stop a slow whole-machine ps from being read as an absent process

`ps -axo ...command=` pays a per-pid argv read: measured 1.15s for 1,948
processes (0.03s without `command=`), and CPU contention stretched the same
capture to 6.0s. Two budgets sized for a cheap look then misreport a readable
machine.

The reader's 3s ceiling killed 6 of 20 consecutive captures at load 27, so
every consumer answered "unverifiable" about a table it could read. Raise it
to 15s, and stamp the capture instant at ps START so `capturedAgeMs` is the
upper bound its contract promises -- a 6s capture used to report itself as
freshly taken, understating staleness against a 5s kill gate. The TTL keys on
completion so a slow capture still coalesces instead of forking ps per caller.

`readStructuredTuiProcessIdentity` then spent its whole 5s wait inside one
capture and concluded "no exact child" after a single look taken before the
child existed (observed landing at ~3.5s). Absence needs a look that did not
race the spawn, so require two captures before the deadline can end the loop.

Both surfaced by the real-binary Claude TUI resume test, which failed ~1 in 5
under load; 14/14 now, 8 of those runs containing a capture the old 3s budget
would have killed.

* Let the desktop renderer negotiate Claude structured tabs

The paired-client gate hides agent-session rows an agent the client cannot
render. The desktop renderer's own IPC dispatches as clientKind 'runtime'
advertising only agent-session.structured.v1, so the gate hid Claude rows
from the surface this feature ships on. It renders them; it should say so.

* Stop a slow process table from silently blinding every freshness gate

Stamping `capturedAgeMs` at ps START made the number honest, and honest broke
both consumers that read it. `ps -axo ...command=` measured 2.5-9.0s on an idle
2,002-process laptop and 4.0-18.6s at load 46, so the age it now reports lands
past every budget: `planRelayPtySweep` refuses the stop as "too old", and the
renderer's `admitRemoteForegroundEvidence` refuses the record outright. That
second one is the expensive half and was outside the diff -- a refusal bumps
`consecutiveInspectionErrors`, the poll scheduler backs off to its 10s floor,
and agent-completion detection stops for the pane. The subsystem went blind on
exactly the loaded hosts the honest stamp was meant to serve.

The evidence-publishing read now gives up at 1,200ms instead of waiting out
`PS_TIMEOUT_MS`. It is one budget for one question: these consumers ask whether
an observation describes NOW, and past this it does not -- a late answer is
refused by the age gate anyway, having first blocked a polled path for the whole
capture, so a prompt `unverifiable` is both the truthful verdict and the cheap
one. Both relay call sites already produce it from a rejection, and an admitted
`unverifiable` costs a poll where a refusal costs the cadence. Identity proof
keeps the full 15s through `getFreshProcessTableSnapshot`, because it asks
whether a process EXISTS and must never read slow as absent. The budget bounds
the wait, never the capture: the reader coalesces, so an abandoned wait leaves
its capture running to fill the cache rather than forking a second whole-machine
`ps` on the host that can least afford one.

1,200ms is bracketed rather than picked. The floor is the capture's own cost --
`command=` measured 1.15s for 1,948 processes on an idle host, and a budget
under that answers `unverifiable` about a machine nobody is straining. The
ceiling is the consumer's: 2,000ms, less the 500ms a TTL-shared capture may
already have aged, leaves 1,500ms, and transit takes the rest.

That ceiling only fits once the capture stops being charged twice. `ps` runs
inside the RPC round trip, so its duration is already in `receiveDelay`, and
`capturedAgeMs` is that same duration on the host's clock; summing them halved
the budget this gate grants a host from ~2.0s of `ps` to ~1.0s, which is why a
1.2s capture arriving at 1.3s read as 2.5s old and was refused. Admission now
takes the larger of the two. The sweep's gate keeps its sum, which is correct
there: `evidenceAgeSinceListingMs` is stamped after the listing ARRIVES, so it
measures planning time and overlaps nothing.

A stated limit rather than an assumed one: 15s is not proven sufficient for
identity proof. The same capture reached 18.6s at load 46, so that path can
still time out and answer "no exact child" about a host it simply could not read
in time. Narrowing it needs a cheaper question than a whole-machine argv read,
not a larger number.

The one test guarding this field could not fail. `beginPtyHandlerTest` installs
fake timers, so `Date.now()` is frozen, the real reader reports exactly +0, and
`0 <= 500` held identically for a hardcoded zero, for completion-stamping and
for start-stamping -- while the real reader on that host returns thousands of
ms. It now drives a measured age in and asserts the handler publishes it rather
than restamping; that the reader MEASURES it correctly stays pinned separately,
against a controllable clock. Both consumers get boundary coverage either side,
and each new gate was ablated red before it went green.

* Keep the compatibility fields off the capture the budget just abandoned

inspectProcess falls back to processHasChildren and listProcesses to
getForegroundProcessName, and both read the same TTL-shared capture with
no budget of their own. On a slow host they joined the in-flight capture
the budgeted evidence read had just given up on, so the call still blocked
for the full 6-18s and the budget bought nothing -- once for inspectProcess
and once per managed PTY for listProcesses.

Use the degraded answers those helpers already give for an unreadable
table, reached promptly. pty.hasChildProcesses keeps its unbudgeted fresh
probe: it is a one-shot destructive gate that can afford to wait.

---------

Co-authored-by: Merge Sim <merge-sim@local>
Co-authored-by: Merge Sim <sim@local>
2026-09-04 15:55:20 -07:00
Neil d3501f7ad6 fix(worktrees): stop a failed worktree scan from being recorded as an authoritative empty listing (#18456)
* fix(worktrees): stop a failed worktree scan from pruning as an authoritative empty listing

A `git worktree list` that could not run at all — a WSL distro that stopped
resolving, a hung mount, a git binary that errored — was softened to `[]` by the
lenient listing path, so the detected scan published it as
`authoritative: true, worktrees: []`. That disables the #1158 retention guard,
drops every persisted tab for the repo, and the pruned session is written back to
disk on the next launch. The loss is permanent, not a transient glitch.

Route the detected scan through a strict listing that still reports the two
genuinely empty states (repo path gone, not a Git repo) as `[]`. Everything else
rejects, so the existing catch answers `authoritative: false` and the destructive
halves (`rememberLocalWorktreeRoots`, `pruneLineageForMissingRepoWorktrees`)
never see a failed scan.

Also make the failure readable: wsl.exe reports its own launch failures as exit
0xFFFFFFFF with an EMPTY stderr and the `Wsl/Service/WSL_E_*` line on stdout as
UTF-16LE, which is why the field bundle carried a git error with no text. Set
WSL_UTF8 for WSL-routed git (matching the wsl runner, #9010) and attach that
stdout diagnostic to the error so `git.exec` spans name the cause.

* fix(worktrees): surface a failed worktree scan's cause on the repo header with a retry

A failed scan now travels with its reason (optional unavailableReason on
DetectedWorktreeListResult), the repo header shows it with click-to-retry,
and the WSL deleted-guest-directory shape measured on a real Windows host is
pinned as retained-not-pruned.
2026-09-04 15:23:09 -07:00
Neil 901c6771ff fix(agents): detect agent CLIs installed outside a version manager (#18336)
* fix(agents): detect agent CLIs installed outside a version manager

The install-dir fallback that answers "is this agent installed?" when the
login-shell PATH probe fails listed only version-manager bin dirs, so
codex/opencode/cursor-agent installed by Homebrew, npm's default global
prefix, snap, nix, or the CLI's own installer read as not installed.

Where this is decisive, corrected from the previous message: the `orca` CLI,
whose detectSkillsCliAgentKeys (src/cli/handlers/skills.ts) calls
detectCommandsInInstallDirs directly and whose entry point seeds no PATH, plus
unpackaged/dev runs. NOT the packaged desktop app: patchPackagedProcessPath
(configure-process.ts:114) appends /opt/homebrew/bin, /usr/local/bin,
~/.opencode/bin and the Linux/nix prefixes onto process.env.PATH at
main-process-preflight.ts:147, before any detection, and mergePathSegments
preserves them, so the PATH scan reaches those dirs first and this fallback
never fires for them. That means this does NOT explain the packaged macOS
v1.4.194 report of codex/cursor-agent/opencode all undetected -- that report
stays open and uninvestigated.

Second correction: the fallback now carries the prefixes Homebrew actually
uses on Linux (/home/linuxbrew/.linuxbrew/bin), plus /snap/bin and the two nix
profile dirs, matching what patchPackagedProcessPath already seeds. The WSL
guest prelude gains the same entries. Leaving them out closed the native/WSL
asymmetry on darwin only, on the platforms where the fallback is decisive.

Appended last so a version-manager install still wins, and kept out of
getVersionManagerBinPaths, whose result is PREPENDED to PATH (#18234). Lives in
its own module so node-cli-command-resolution.ts stays under max-lines.

The test stages every path through `join` and asserts via
detectCommandsInInstallDirs as well as resolveCliCommands, so it holds on a
Windows dev machine and pins the "absolute path means installed" contract.

* fix(agents): align system install-dir order across the three PATH lists

Round-2 review remediation. The blocking finding was about the handoff
artifact, not the code: the summary handed to review described a 2-file/+38
change with 3 new macOS dirs, while HEAD is 4 files/+227 with 6 lookup dirs
plus 5 new WSL-guest prelude entries, and the quoted failing test names never
existed. Restated against HEAD in the handoff; no rebuttal, the reviewer was
right.

Justification for the entries the summary never described: the fallback exists
to close the native/WSL asymmetry for a CLI no version manager installed, and
patchPackagedProcessPath already seeds Linuxbrew, /snap/bin and both nix
profile dirs (configure-process.ts:141-158). Shipping only the darwin subset
would have left a Linux or WSL user with a snap/nix install still reading as
not installed while the packaged macOS user did not -- the asymmetry the change
is for.

Code changes, all from the non-blocking list:

- The three lists disagreed on order while claiming to be kept in step, so a
  CLI in both /usr/local/bin and /snap/bin could resolve to a different binary
  than the seeded PATH scan or the WSL guest probe found. All three now use the
  seed's relative order, pinned by a new duplicate-install test and an offset
  assertion on the prelude. The prelude's system block also moved after the nvm
  glob so a version manager still wins in the guest, as it does natively.
- The parity docstring asserted "the same set patchPackagedProcessPath appends,
  minus the sbin dirs and the generic ~/bin", which was false: it also omits
  ~/.vite-plus/bin (seeded by configure-process.ts:156, but no probed agent
  command maps to it) and /opt/homebrew off darwin. All three gaps are now
  named as deliberate.
- win32 returns [] and stays that way, but the branch now says why:
  %USERPROFILE%\.opencode\bin has never had install-dir coverage in either
  list, and the seed's system block is POSIX-only too. Pre-existing, unchanged.
- The detectCommandsInInstallDirs case read the ambient process.env.PATH, so on
  a box with /usr/local/bin on PATH only the opencode assertion exercised the
  fallback. It now stubs the GUI-launch PATH, so both do.

Unchanged and restated: this does NOT explain the packaged macOS v1.4.194
report of codex/cursor-agent/opencode all undetected, and must not close G6
report #3. patchPackagedProcessPath returns early unless app.isPackaged and
seeds all six dirs before any detection, and mergePathSegments never deletes
them, so the packaged PATH scan reaches them first and this fallback never
fires there. Decisive only for the `orca` CLI's detectSkillsCliAgentKeys and
unpackaged/dev runs.

Verification: the suite is red without the production hunks (5 failed/3 passed)
and green with them (8 passed); 11 related suites pass (197 tests); tc:node,
tc:cli, oxlint, oxfmt and the max-lines ratchet are clean.

* fix(agents): correct the ordering-parity claims and widen the seed-leak guard

Round-3 review found two docstring claims that are false as written and one
guard that only asserted a third of its list.

- system-cli-install-dirs.ts claimed a CLI in two of these dirs resolves the
  same here as in the packaged PATH seed. True inside the system block, false
  across it: `claude` in both ~/.local/bin and /opt/homebrew/bin resolves to
  ~/.local/bin via the fallback (getBaseVersionManagerDirectories leads) and to
  /opt/homebrew/bin via the seed, which appends ~/.local/bin last. Scope the
  claim to the block and name the gap instead of asserting it away. No behavior
  change: closing it would hoist a system dir over a version-manager one
  (#18234).
- posix-version-manager-bin-dirs.ts justified moving "/usr/local/bin" after the
  nvm glob with "a version manager still wins in the guest, as it does
  natively". The glob expands lexicographically; native orders nvm dirs
  default-alias-first (#10932), so that is not parity. Record the move as the
  one behavior change in the file and bound it: entries are appended behind a
  resolved login PATH, and both consumers only test presence.
- The #18234 seed-leak guard asserted only /opt/homebrew/bin and /usr/local/bin
  were absent from getVersionManagerBinPaths, leaving the four other new dirs
  unpinned. It now spells out all seven across darwin and linux -- spelled out
  rather than derived from getSystemCliInstallDirectories, which would pass
  vacuously against exactly the refactor it guards.

Tests 8 passed (8); 5 failed / 3 passed with the production hunks reverted to
origin/main. tsc node + cli clean, oxlint clean.

* fix(agents): find Pi's own installer dir in the CLI install-dir fallback

The fallback added `~/.opencode/bin` but skipped `~/.vite-plus/bin` on the
claim that no probed agent command maps to it. False: `pi` is a probed detect
command on every runtime (`tui-agent-config.ts`, no `detectUnsupportedRuntimes`)
and `~/.vite-plus/bin` is the Pi installer's default — the two dirs #829 named
and `patchPackagedProcessPath` seeds together. Added to both the native
fallback and the WSL guest prelude, so a Pi installed by its own script is
found by the `orca` CLI and in WSL, not just on a seeded packaged PATH.

Also, all narrower:
- `/snap/bin` + Linuxbrew now gate on `linux` like the seed does, instead of
  every non-darwin posix.
- Docstring: `/opt/homebrew` off darwin is the one remaining seed gap and says
  why; the "lookup-only" paragraph names the `withCliRuntimeOnPath` exception.
- New seed-order test derives the expected order from
  `getSystemCliInstallDirectories`, so reordering either list fails.

The PR body's claim that SSH hosts benefit is dropped: they answer
`preflight.detectAgents` from `src/relay/preflight-handler.ts` via
`isCommandOnPathForRelay`, a separate bundle that never reaches this module.

* fix(agents): build the CLI install-dir fallback order once and pin it on both resolvers

resolveCliCommand (every spawn site) and resolveCliCommands (detection) each
spelled the nvm -> version-manager -> system-dir order by hand, which is how
the native and WSL lists drifted apart before. One getCliInstallDirectories
now feeds both, and the test pins system dirs LAST on both resolvers, on
darwin and linux, plus a derived check that the WSL guest prelude keeps every
native version-manager dir ahead of the native system block.
2026-09-04 15:18:07 -07:00
Brennan BensonandMerge Sim f4c2821167 refactor(agent-session-journal): move the session journal onto SQLite (#18652)
* refactor(agent-session-journal): move the session journal onto SQLite

The agent-session journal kept its state in three hand-rolled file formats: an
append-only `log.jsonl` with torn-tail repair, a `snapshot.json` holding folded
state plus a retained tail, and byte-quarantine files for anything unreadable.
This replaces all of it with one SQLite database per session — `journal.db`
beside the existing `blobs/` store — using the in-house adapter and the
open/pragma/migrate/harden pattern the orchestration database already follows.

Two tables: `journal_rows` (the append-only log, keyed by
`(session_id, epoch, seq)`) and `journal_sessions` (the derived projection,
upserted in the SAME transaction as every insert). Rows stay JSON in one
column, so the row schema, the version upcast chain, and the reducer survive
byte for byte — `journal-reducer.test.ts` and four other suites pass unchanged
and are the regression proof.

Deleted: `journal-log-file.ts`, `journal-compaction.ts`,
`journal-corruption-quarantine.ts`, and the public `compact()` /
`compactionBoundary` / `autoCompact` members, none of which had a non-test
caller.

Existing `log.jsonl` / `snapshot.json` journals are deliberately abandoned. No
importer: a session created on the old path stops working, which is acceptable
because the feature is off by default.

## The physical quota is repriced, because SQLite does not charge like a file

The 256 MiB per-session bound is unchanged, but the arithmetic under it could
not survive: SQLite grows the database in pages and the WAL in frames, and the
checkpoint that copies the WAL forward holds the same pages in both files at
once, so a transaction's peak is about twice its content. Admission now charges
the candidate transaction's own measured page cost, validated against a sweep
that runs as a regression test (`journal-database-space.test.ts`) rather than
derived from reasoning about the allocator.

Four things are load-bearing rather than tuning, each measured:

- `auto_vacuum = INCREMENTAL` must be set BEFORE `journal_mode = WAL`. Set it
  after and it is ignored with no error, reclamation silently becomes a no-op,
  and the file never shrinks again. Both halves are asserted.
- `wal_autocheckpoint = 0` plus an explicit `wal_checkpoint(TRUNCATE)` at the
  end of every write path, so the one moment the same pages live in two files is
  a moment the charge accounts for.
- Reclamation runs in bounded chunks. A single unbounded `incremental_vacuum`
  took a 252 MB directory to 504 MB — the reclamation added to defend the bound
  would have breached it. `PRAGMA incremental_vacuum(N)` also frees exactly one
  page unless it is stepped to completion, which no size assertion catches, so
  the freed page count is asserted directly.
- A blocked checkpoint leaves the WAL on disk together with the database growth
  it already copied, so admission charges that deferred copy explicitly. The
  term is zero whenever the last checkpoint succeeded, so the uncontended path
  admits and refuses an identical set.

The epoch discard is `DELETE FROM journal_rows` with no WHERE clause, which
takes SQLite's truncate optimization: measured at ~0.26% of the database in WAL
bytes where the `WHERE session_id = ?` form rewrote every emptied leaf at up to
99%. One database per session is what makes the unqualified form correct.

An open, empty journal costs 57,344 bytes before a single row exists, so a
configured quota below `JOURNAL_MIN_SESSION_BYTES` now fails loudly at open with
the existing `journal_bound_exceeded` instead of as a run of identical append
failures. No production caller configures one; the affected surface is test
fixtures, rescaled to the smallest value that restores what each case proves.

## One deliberate behaviour change

Compaction was the only mechanism that shed bytes inside an epoch, and the write
path called it precisely so an append at the bound was not refused. The
SQLite-shaped replacement — a bounded prefix delete — cannot be used: with the
snapshot gone the surviving rows ARE the state, so dropping the oldest of them
loses the oldest transcript silently at the next reopen. So no row is ever shed
inside an epoch, and a session whose row bytes alone reach the bound now refuses
every append where it previously compacted and continued. A loud typed refusal
beats silent data loss.

What still sheds is unreferenced BLOB bytes — the dominant and unbounded byte
source — on the same write-path hook. The escape from the hard stop is the fold
that already exists, `replaceEpochItems`, which now actually returns bytes to
the filesystem instead of leaving them on the freelist.

The prune's protected set is a union of live reducer digests AND the candidate
row's own digests, including those cited only by a nested lifecycle-batch
mutation. Content addressing never rewrites a digest already on disk, so
protecting live state alone deletes the blob the append is about to cite — a
dangling reference that surfaces one reopen later as an empty expansion on an
item the user can see. `journal-store-blob-budget.test.ts` pins it, and it goes
red when the set is narrowed back.

## Handle ownership

A file handle used to be opened and closed per append; a SQLite handle is held
for the session's lifetime. Every path that can open a connection now has one
owner: the open function owns its raw connection until it returns, the store
owns its retained one and releases it in a new `close()`, and every other
connection is closed by the call that opened it. The attach, recovery,
eviction, map-overwrite and host-teardown paths close what they drop, and host
teardown is failure-complete — the sink-barrier flush throws by design, so a
trailing close statement would be skipped on exactly the path that leaks.

`close()` has a stated contract: admission at enqueue and permanent, the close
step on the same queue past that gate, one shared in-flight attempt, fulfilment
terminal, and the release last and deliberately unguarded so a retry re-enters
it. Guarding the release would skip it on retry, guaranteeing a permanent leak
in exactly the case where it did not release.

`journal_closed` joins the error union for a write after `close()`; no file
outside the directory references any of these codes.

* fix(agent-session-journal): make a COMMIT final, stop repairs deleting valid rows, and keep rejected closes retryable

Six review findings on the SQLite journal migration.

1. A successful COMMIT is now the point of no return. The ordinary append,
   the epoch roll and the epoch replacement each adopt the committed row or
   epoch BEFORE any post-commit filesystem work; checkpoint, reclaim, blob
   prune and directory measurement run through `runJournalPostCommit`, which
   is best-effort by design and falls back to the transaction's own charge as
   a conservative footprint. Previously a post-COMMIT scan failure rejected a
   durable append and the next one reused its sequence, and a failed epoch
   housekeeping step left the store writing into a prefix already deleted.

2. Corruption repair preserves instead of destroying. A rejected suffix is
   copied into a new `journal_quarantine` table and removed from the live
   epoch in ONE transaction per chunk, charged against the session bound
   before a byte is written; a journal that cannot afford the copy refuses to
   open rather than falling back to deletion. The repair state is exposed as
   `journal.repair` and the rows are readable through
   `recoverQuarantinedRows()`, so Orca-owned submission, receipt and
   lifecycle identity survives a gap or a malformed row.

3. The physical charge covers the B-tree key payload. `session_id` and
   `epoch` are stored in both tables and both primary-key indexes and appear
   nowhere in `row_json`, so the journal boundary now bounds them and
   `journalTxnPhysicalCost` charges those bounds plus the projection upsert.
   The charge sweep runs the exact production transaction at maximum admitted
   key sizes.

4. A rejected `close()` no longer orphans its handle. Callers hand the
   journal to `agentSessionJournalCloseRetries` instead of swallowing the
   rejection, the attach map replacement is ABORTED when the previous
   journal will not close, host teardown retries what the registry holds, and
   a failed runtime teardown is retained so the next stop is a real retry.

5. `journalWalBytes()` returns zero only for ENOENT and propagates every
   other stat error, so admission and reclamation fail closed.

6. The WAL contention test closes the writer before removing its temp root
   and asserts the directory is removable once handles close.

Regression coverage: post-commit divergence (4), corruption repair (5),
key bounds (5), WAL stat (8), close retry (5), plus a runtime stop-retry
case. Each fix was ablated on this head and the matching tests go red.

* fix(agent-session-journal): anchor replay at sequence 1, make quarantine append-only, and charge it in bytes

Three ways the corruption quarantine still lost rows it was written to keep.

Replay validated contiguity from the first row that HAPPENED to remain, so an
epoch missing only its sequence-1 row declared the leftovers contiguous and set
no `truncateFrom`. The load was still corrupt, so recovery imported provider
history and `replaceEpochItems` deleted every live row — including Orca-minted
submission, receipt and lifecycle identity that no transcript can reconstruct,
and that nothing had quarantined. Replay now anchors at sequence 1, so a missing
epoch row rejects the whole surviving range before any replacement runs.

`journal_quarantine` was keyed on `(session_id, epoch, seq)` and copied with
`INSERT OR REPLACE`. A repair frees the sequences it removed and the live epoch
reuses them, so a second repair in the same epoch silently deleted what the
first preserved. The table is now keyed on a surrogate `quarantine_id`, the copy
is a plain append, and `(epoch, seq)` is metadata; existing v1 databases are
rekeyed in the migration that already bumps `user_version`.

The admission charge read `length(row_json)`, which counts CHARACTERS for a TEXT
value where `journalTxnPhysicalCost` expects physical UTF-8 bytes. A multibyte
suffix was charged at up to a third of what it writes, which defeats the
pre-write physical bound — over a megabyte on a maximum-size lifecycle batch.

* fix(agent-session-journal): keep a repaired epoch anchored and stop the v1 quarantine migration doubling the file

Replay validated numeric contiguity from sequence 1 but never that sequence 1
IS the epoch row. When the anchor was missing the repair set aside every
surviving row, and if provider-history import then failed — a transcript that
is temporarily gone is enough — the journal reopened as a clean, row-less
epoch: an ordinary append took sequence 1, replay accepted it, read-restore
published it as history, and automatic recovery never ran again while the
user's real messages sat in quarantine.

Replay now rejects an unanchored prefix, the open publishes an
`unreconcilable_prefix` anchor for an epoch its repair emptied, and that anchor
keeps reporting corrupt — so provider history is retried on every attach —
until the timeline is rebuilt or the session writes content of its own. A
repair also discloses rows it set aside when no line was unreadable at all,
which is the case that removes the most.

The v1 quarantine rekey copied every legacy row into the new table inside one
transaction and dropped the old one. A quarantine holds whole rejected rows: a
single 8 MiB row nearly doubled the database past the physical bound the open
had already checked, the dropped pages only reached the freelist, and the next
open refused the session it had just migrated. The v1 table is renamed and
frozen instead, and reads take both generations. Table creation also moves
inside the migration transaction, so a crash can no longer leave a v2-shaped
database still reporting version 0 for an older build to write into.

* fix(agent-session-journal): stop an empty provider transcript retiring the repair marker

A transcript that exists but decodes to zero messages was imported as a
success: the import published an empty `legacy_import` replacement that
deleted the `unreconcilable_prefix` anchor and its disclosure, so the next
probe read the session as clean and every later attach skipped provider
recovery while the user's rows sat in quarantine for good.

The import now leaves the epoch untouched when nothing decodes, reporting
`replaced: false`, and recovery treats that like a transcript it could not
read — the marker stands and a later attach with real history rebuilds the
timeline.

* style(agent-session-journal): merge the duplicate journal-database-space import

* refactor(agent-session-journal): drop quarantine, byte bound, blob spill and rate limit

Match what comparable implementations do: the journal is an unbounded
append-only SQLite log with no side tables and no admission control.

Corruption: the rejected suffix is DELETED rather than copied into a
quarantine table. The load still reports `corrupt` and recovery still
rebuilds the epoch from provider history, so the observable outcome is
unchanged — only the preservation half is gone. The schema is back to one
version with two tables; no v1 database exists outside unmerged commits of
this branch, so the rekey migration and the two-generation read path go with
it. Sequence-1 epoch anchoring and the empty-provider-transcript retry are
kept: both are about the corrupt signal being correct.

Size: no `maxSessionBytes`, so no page-cost arithmetic, reclaim band,
incremental vacuum, lifecycle byte reservations or `journal_bound_exceeded`.
`auto_vacuum` and `wal_autocheckpoint = 0` existed only to make a
transaction's physical cost predictable for that charge; with the charge gone
SQLite's default checkpointing is what the journal wants, and the explicit
pre-close checkpoint is redundant with the one `db.close()` performs. WAL,
`synchronous = FULL` and `busy_timeout` stay.

Payloads: an oversized body is truncated at the existing inline cap with the
existing marker and the remainder is discarded, bounded at the translation
layer that already calls these helpers. The truncation point and message do
not change; the content-addressed blob directory and all digest tracking do.

Rate: no `maxAppendsPerWindow` and no `journal_rate_exceeded`.

`JournalPayloadLimits` is now just the inline cap.

* fix(agent-session-journal): mark a partial repair pending and bound multi-block tool input

A repair that keeps its prefix had nothing durable to show for the suffix it
deleted: a sequence gap costs no malformed row, so no disclosure is appended,
and the surviving rows keep their epoch anchor. The next probe read a
contiguous anchored prefix, called it clean, and the deleted stretch of
timeline was never asked for again — silent loss, with the deletion already
committed. The deletion now writes a `journal_repairs` marker in the SAME
transaction, and replay keeps reporting corrupt while it stands. It retires
under exactly the rule the emptied-epoch anchor takes: a fresh epoch carries
the rebuild, or the session writes content of its own past the sequence the
repair left free. The repair's own disclosure is not that content.

Legacy import bounded a tool call's input only when it was the message's sole
block; the multi-block path returned `tool-call` unchanged, so a mixed message
from Claude, Grok or an omp execution cell persisted the whole input despite
`inlineHeadBytes`. `boundBlock` now routes it through `boundToolInput`.

Also drops canonical comments describing quarantine, snapshot files, blob
storage and blob compaction — none of which exist any more.

* fix(agent-session-wire): stop awaiting the synchronous journal probe

loadJournal runs on a sync-database connection and returns JournalLoad | null, so both wire call sites were awaiting a non-Promise. The type-aware code-quality gate flags it; the native gate does not.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-04 15:09:23 -07:00
Neil a7223be538 perf(agent-hooks): find spool newlines with Buffer.indexOf, not a per-byte loop (#18639)
readSpoolFile walked every byte of every spooled JSONL file in an interpreted
loop to locate newlines. drainAgentHookSpool runs inside start() before the hook
listener binds, over up to AGENT_HOOK_SPOOL_MAX_FILES files, so this sits on the
startup path.

Buffer.indexOf reaches the same newlines via memchr. A 5.3MB spool file drops
from 32.50ms to 9.26ms per read. The torn-trailing-line contract is unchanged:
a final line with no newline is still left unconsumed.
2026-09-04 14:33:28 -07:00
Neil 1b4159a318 perf(sidebar): stop building host projections the row model throws away (#18638)
* perf(sidebar): stop building host projections the row model throws away

getMixedHostContextLabels built a label map for every visible worktree and then
returned undefined unless two distinct hosts existed — so a single-host install,
the common case, paid the whole build on every buildRows rebuild. Decide first,
then build only when it is mixed.

getHostWorktreeCounts repeated getHostWorktreeIds' dedupe walk to compute a
number that is exactly the id list's length, and both loops built the identity
string twice per row. Derive the counts and hoist the identity.

* fix(test): correct the worktree types import path and drop an unused parameter
2026-09-04 14:33:17 -07:00
Neil fb99bcc3a4 perf(terminal): gate the Command Code banner scan before building its scan windows (#18628)
Every PTY chunk on every pane built two ~4KB scan windows and then, for panes
that have not shown the banner, stripped terminal control sequences three times
over them. The existing prefilter ran after that work and only required the
letters C, o and d, which nearly every code agent's output contains.

COMMAND_CODE_BANNER_RE requires a literal '#' and stripTerminalControl only
removes characters, so raw bytes without one cannot match. Check that against the
carry and the chunk before either window is built.
2026-09-04 14:32:27 -07:00
Jinwoo Hong ef428d879e feat(relay): tell the phone when its desktop is signed out (#18698)
On 2026-09-04 an auth outage signed ~21,600 desktops out of Orca Cloud and
every paired phone showed the generic "Can't reach desktop" for hours. The
desktop knew why, the cell watched it happen, and neither could say so.

The desktop now names auth loss on its control close reason; the cell
remembers that reason per (userId, relayHostId) and replays it as the close
reason of the 4404 it already sends a phone whose host is absent; the phone
turns it into "Desktop signed out — sign in to Orca on your desktop to
reconnect". Retry cadence, close codes and every message body are untouched.

The reason rides the WebSocket close reason because there is no additive JSON
channel to a shipped phone: RelayPhoneHelloSchema, RelayAuthSchema and the
director's ResolveResponseSchema are all zod .strict(), and /v1/connect
rejects any query string outright. A new close code was also rejected — an old
phone would fall out of mobileRelayRecoveryFor and off the 5-15s host-offline
backoff onto the faster transport backoff.

The cell keeps the reason in memory rather than Postgres: a phone reaches the
cell its host's assignment row already names, which is the cell that saw the
close, and losing it on a cell restart degrades to today's verdict rather than
a wrong one.
2026-09-04 16:51:49 -04:00
Jinwoo Hong 77334e7c8b fix(orca-profiles): tell the renderer when a cloud session is revoked (#18694)
A revoked refresh-token family makes the main process clear the stored
cloud session, but the renderer cached orcaProfileAuthStatus at startup
and only re-fetched when it was empty. The account card kept showing
"Connected" and Mobile pairing kept showing a generic Relay failure until
the user restarted the app.

- Push: clearing a session on an auth failure now emits an invalidation
  event that broadcasts orcaProfiles:authStatusChanged to every window;
  the renderer re-reads auth status from it. Explicit sign-out is unchanged.
- Pull: every pane that renders auth state re-reads it on mount through
  useOrcaProfileAuthStatusRefresh instead of only when the store is empty.
- Copy: a Relay mint failure re-reads auth status, and the failure notice
  says the session expired and to sign in again instead of offering a
  retry that cannot succeed. The LAN path is untouched.
2026-09-04 16:51:45 -04:00
Neil 0d2375a7ff fix(remote): stop a disclosure list latching the mirror completeness gate (#18619)
* fix(remote): stop a disclosure list latching the mirror completeness gate

`hostScope.omittedHostIds` was doing two jobs with opposite requirements. As
disclosure it must over-name: `omitted-host-scope-selectors.ts` deliberately
keeps ids for servers that are no longer paired so a caller can still see the
gap, and `docs/reference/ssh-execution-boundary.md` requires a listing to name
what it did not cover. As a completeness gate it must name only coverage that
was owed and not delivered, or it latches.

It latched. `workspaceSessionsByHostId` keeps a partition for every runtime a
machine has ever paired with and nothing prunes it, and a mirrored `remote:` row
names its peer too — so any client that has ever paired outward publishes a
permanently non-empty `omittedHostIds`. `probeHostLiveTerminals` read that as
`unverifiable`, `markHostSessionMirrorHydrated` never fired, and panes parked on
`parkUntilHostSessionMirrorHydrates` never drained.

`hostScopeCensusIsComplete` gives the gate its own answer and leaves the
disclosure list alone. A `runtime:` host is never owed coverage by the runtime
answering: a paired runtime is a peer with its own control plane reached with
`--environment`, and there is no paired-runtime PTY provider for this runtime to
have queried. Two other branches stay load-bearing — an absent scope is a host
too old to claim one, and a listing that covered no host proves nothing.

No wire change: the host publishes byte-identical content and only the client's
reading moves, so this reaches the reporter by updating their client alone
rather than waiting for their remote. That also avoids a new field's fallback
rule, where "absent means complete" would recreate the bug with the polarity
flipped.

`queried-host-kinds.test.ts` pins the invariant the predicate rests on at its
source, because the consolidation moving the SSH path onto orcad is the change
most likely to introduce a runtime-backed PTY provider and quietly invalidate it.

Fixes #18595

* test(remote): pin the orphan-recovery host-scope gate and narrow the invariant claim

The readiness review found the second gate unpinned: reverting
`web-session-terminal-orphan-recovery-inventory.ts` alone to the pre-PR
expression left the whole renderer suite green, because every existing fixture
passes `omittedHostIds: []`. The commit claimed two gates and proved one.

Four cases now drive `resolveTerminalOrphanInventory` through a non-empty scope.
Reverting that gate alone fails the peer-runtime case. Note the absent-scope case
deletes the key rather than passing `undefined`, because `listResult` substitutes
its default for `undefined` — routing through the fixture there silently tests
the default instead.

`queried-host-kinds.test.ts` also claimed more than it caught: a runtime-backed
transport registered under an SSH connection id reports as `ssh:` and passes,
which is the shape the orcad consolidation is expected to take. It pins the
spelling this function emits, which is what the gate keys on, and now says so.

* fix(remote): require a legible covered host before believing a census

CodeRabbit found a real asymmetry: the predicate refused an omitted host id it
could not parse, but accepted an unparseable *covered* id as proof of coverage.
`isTerminalListResult` validates only that `hostIds` is an array, so
`{hostIds: ['runtime:'], omittedHostIds: ['runtime:env-7']}` was `unverifiable`
before this PR and would have become `complete` after it.

Taken as "at least one legible covered host" rather than the suggested "every id
parses". A host that later gains a kind this client cannot parse would otherwise
report an incomplete census forever — which is this bug in a new coat, and the
failure mode the predicate exists to prevent.

The check exposed four tests publishing `hostIds: ['remote-runtime']`, a bare
environment id that `parseExecutionHostId` rejects. No host emits that: a runtime
answering `terminal.list` names the execution hosts it covered, which is `local`
— verified against a live paired runtime. Those fixtures are corrected to the
shape the wire actually carries, which is why the assertions move.
2026-09-04 06:09:07 -07:00
Neil 637dc30a32 fix(relay): observe Windows PTY child processes instead of answering false (#18591)
* fix(relay): observe Windows PTY child processes instead of answering false

`processHasChildren` returned a hardcoded `false` on Windows, and a hardcoded
negative is indistinguishable from a measurement. Every close guard reads it as
"nothing is running in this pane", so an SSH-to-Windows tab running a build
closed with no prompt. Measured on a real Windows SSH host: a live `PING.EXE`
under the pane's `cmd.exe` still reported `hasChildProcesses: false`, while the
identical harness on Linux reported `sleep` / `true`.

Windows has no `ps`, but it does have a process table, and the pane walk over it
already existed for the foreground reader. The answer now comes from
`queryWindowsPaneProcessInventory`; a table it could not read reports
`unverifiable` rather than a fabricated negative.

`hasChildProcesses` is a boolean, which cannot hold the third answer, and it is
read both as "busy, do not close" and as "the agent took the PTY, safe to type
into" — so no single mapping of `unverifiable` is safe for both. The verdict
moves to a new optional `childProcessEvidence` member that the close paths read;
the boolean keeps its exact meaning for every client that cannot.

Cost: `pty.inspectProcess` is the polled path and a relay host has no
`@vscode/windows-process-tree`, so its table read falls back to the 1.36s CIM
scan. Polling that would reinstate the fork storm the shared table exists to
prevent, so only a caller whose answer decides something asks for the scan.

* fix(runtime): forward scanChildProcesses through the environment inspection RPC

`guardRunningTerminalClose` asks the host to pay for a real child-process read,
but the environment path dropped the option before it reached the wire: the
renderer sent only `expectedIncarnationId`, and the RPC schema — the shared
`TerminalHandle` — silently stripped anything else. A host routing that pane
through an SSH relay then declined to scan and answered `unverifiable`, which
`inspectionReportsRunningWork` reads as running work. The result was a close
confirmation on an idle pane, which is the nag this PR exists to avoid.

Forwarded through all four layers: renderer payload, RPC schema, method handler,
and the runtime/controller signatures. The schema is a dedicated extension rather
than a field on `TerminalHandle`, so `clearBuffer`/`agentStatus`/`isRunningAgent`
keep refusing an option they have no use for.

The silent strip is not itself the defect — it is what makes a new optional member
safe to send to an old host, per docs/reference/remote-wire-compatibility.md. The
defect was the schema and its caller drifting inside one version, so the tests pin
the registered method rather than the schema alone: pointing it back at
`TerminalHandle` compiles, parses, and drops the option.

Found by review on #18591.

* fix(terminal): teach the shared running-work probe the third child-process answer

Rebasing onto main landed `probePtyRunningWork`, which is a better home for this
than the close guard: it already speaks `live` / `unverifiable` / `exited`, and it
exists so the tab-close and window-close guards cannot drift. The child-process
verdict belongs there, not in a parallel predicate beside it.

So the mapping moves into the probe and `inspectionReportsRunningWork` is deleted
rather than kept alongside. The probe now asks for the scan, and a host that could
not observe the pane reports `unverifiable` instead of collapsing onto `exited` --
which is what `hasChildProcesses: false` meant on every Windows relay.

The pane-close path is routed through the same probe for the same reason; it was
the third caller asking this question through a direct inspect of its own.
2026-09-04 02:11:09 -07:00
Neil 766b5b153c fix(relay): release the ConPTY conin handle after teardown, not before it (#18601)
A Windows SSH relay leaked one Windows File handle per terminal, for the life of
the relay process, across reconnects. node-pty's `kill()` flips `readable` on the
conin and conout sockets and destroys neither; `_cleanUpProcess` destroys
`_outSocket`, so only conin is stranded, and it wraps a real named-pipe handle
from `fs.openSync(term.conin, 'w')`.

The obvious fix -- and the one config/patches/node-pty@1.1.0.patch ships for the
desktop -- releases it at the top of the branch, before `_getConsoleProcessList()`
forks and before the native kill. Measured against a real Windows SSH host, that
is three times worse than leaving the leak alone: teardown aborts partway, the
forked console-list agent is never reaped, and both pipe handles stay alive.
Releasing it at the end of the branch instead is flat.

20 spawn/kill cycles, handles bucketed by NT object type, identical numbers
standalone and through a real relay:

  published node-pty        File +1/terminal,  Process flat
  desktop patch placement   File +2/terminal,  Process +1/terminal
  released last (this)      File flat,         Process flat

`windowsTerminal.js` takes the desktop's error-listener hunks verbatim. The conin
listener is not what fixes the leak -- adding it alone changed nothing -- but it
is what keeps a pipe error retiring one terminal instead of the host.

The desktop patch has the early placement and therefore the regression, measured
against its exact installed tree. Correcting it there needs its own verification
on a Windows desktop build, so the trees diverge on this one hunk deliberately and
a test pins that so a future patch sync cannot copy the bug back.
2026-09-04 01:56:42 -07:00