Commit Graph
12225 Commits
Author SHA1 Message Date
Brennan Benson e463aea99c fix(agent-launch): keep a short-lined multi-line prompt on a local zsh launch line, as main did
#24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste.

The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early.
2026-09-30 23:21:15 -07:00
Brennan Benson d2680896f7 feat(agent-launch): host-side prompt delivery for agent.launch
The host's agent.launch typed any launch prompt into the shell as part of the
launch command. A long or multi-line prompt then ran line by line in the
shell, and an agent that never showed readiness or crashed at startup had
nothing guarding where its text went.

agent.launch now carries a prompt on the typed line only when the line stays
one line, control-free and at most 512 bytes; otherwise the agent starts
clean and the host pastes the prompt once the agent's own ready signal fires
(bracketed paste plus its composer marker or a quiet render, read only after
the shell's last hand-off, never while the pane's own shell is proven in
front), with main's draft-paste bytes and an Enter 50 ms later. Orchestration
worker starts wait on tui-idle as before. A replay-safe launch admits and
claims its ledger row in one write, Qwen Code gets a second Enter, the
desktop and phone share one launch-refusal classifier, and hosts advertise
agent.launch.prompt-carry.v1.

Split out of #23748, which moves the desktop source-control buttons onto
this path.
2026-09-30 18:34:51 -07:00
Brennan Benson b594898533 test: stop main failing on three tests the store and close-cause changes crossed (#24231)
* test: two tests catch up with the journal-database store and the close cause

#24006 moved the session store into the chat journal database, and #23467 made close take a cause; #23684's and an older tab-table test still used the old calls, so main's typecheck fails.

* test: the startup-reconcile close test passes the close cause too
2026-09-30 16:03:20 -07:00
Brennan Benson 3727100cc9 fix(opencode): report OpenCode 2 status from each pane's own TUI (#23722)
* fix(opencode): report OpenCode 2 status from each pane's TUI

OpenCode 2 serves every pane from one shared server whose env names only
the pane that started it, so every same-folder pane's work showed on that
pane, and the plugin's single aggregate swallowed the Idle of a pane whose
turn ended while another pane was busy.

Install the status plugin a second time as an OpenCode 2 TUI plugin
(plugins/<name>-tui/tui.js, written only when its bytes differ, locally,
in overlays and in the SSH/WSL relay installs). In a TUI process setup()
runs the same engine, fed through the same event translation as the
server, but only for root sessions this pane owns: the route's session
from when it starts (or when the route reaches it while running, hydrated
from the TUI's session status) until it settles, is deleted, or the TUI
exits. The server plugin stands down in any serve process when the TUI
copy is installed beside it; a relay that predates the TUI copy keeps the
old behavior. OpenCode 1 loads no plugin directories and is unchanged.

Delete the session-to-pane binder, registry, client sweep and ingest
reattribution: with every post stamped by its own pane nothing is left
for them to correct.

Known gap: OpenCode 2 `opencode run` in a pane has no TUI, so it reports
no pane status.

* fix(opencode): settle missed OpenCode 2 turn ends and order TUI installs

- The TUI copy now settles an owned root whose run the TUI's session data
  reports ended, with no pending permission or form, when the engine still
  holds it busy. An execution end missed across a service restart or
  reconnect no longer leaves the pane Working until the TUI exits.
- The TUI copy stays idle without ORCA_PANE_KEY. post() cannot report without
  it, so OpenCode 2 TUIs outside Orca no longer run the route poll and engine.
- Installers write the TUI copy before the server plugin file. The server
  decides at load whether to stand down, so a reload between the two writes
  now finds the TUI copy.

* fix(opencode): reconcile OpenCode 2 TUI status only once queued events drain

The TUI's session data applies each event before this plugin's queued handling
reaches it, so settling against it mid-backlog published a false Done before a
fast turn's later steps (Working, Done, Working, Done). Reconcile only when no
event is queued; a mismatch then is a start or end missed across a reconnect,
and both directions are now re-derived (a missed start left the pane on Done).

Also install the TUI copy beside the server plugin in the retired shared hooks
dir, so a TUI or service still loading that dir reports per pane instead of
leaving the service reporting under its starter pane.

* fix(opencode): keep OpenCode 2 TUI panes silent on plugin dispose

A TUI plugin hot reload disposes the plugin while the pane's turn keeps
running. The server path already passes sessionsOutliveDispose so dispose
publishes nothing; the TUI adapter now does the same, or every TUI reload
would still show a false Done. Also refreshes the generated-bytes digest
after rebasing onto the write-if-changed installers.

* fix(opencode): refresh installed OpenCode status plugins at app start

After an Orca upgrade, an OpenCode 2 service that was already running kept
the previous plugin, and with it the old wrong-pane status, until any new
terminal pane rewrote the file. OpenCode 2 reloads a plugin whose file
changes, so Orca now refreshes its existing installs once after the first
window shows: the global config dir, source overlays and the retired shared
dir, TUI copy first. It reuses the per-pane writers, which skip unchanged
files, never creates an install the user did not have, and honours the
status-hook and per-agent switches. An SSH relay does the same for its
canonical install when Orca connects and ships the plugin sources.

The plugin source assembly moves to its own module (re-exported unchanged)
to keep hook-service.ts under the line limit.

* fix(opencode): derive each OpenCode 2 pane's status from its TUI session data

The TUI copy of the status plugin translated OpenCode events into the
server-side engine and then patched the engine's latches back toward the
TUI's own session data: a settle on every tick, a queued-event counter, a
re-assert, synthetic Busy and Idle. Each review found another place where
the two copies disagreed: a missed end left the pane Working, a backlog of
slow posts flickered Done, a missed start showed Done mid-turn, a turn held
only by a subagent never settled, and a request answered while disconnected
pinned Needs input.

The TUI reporter now reads the pane's level straight from the session data
OpenCode keeps current (and re-hydrates on reconnect) on every event and on
a 100 ms tick: for each root session this pane owns, Needs input (an open
permission, else form, anywhere in its family while it runs) outranks
Working (any family member running) outranks Done. It posts one status per
level change through the plugin's existing delivery functions (retry,
dedupe, message-part throttle, ordering), so the wire and Orca's ingest are
unchanged. Ownership is kept in OpenCode's storage.memory, which survives a
plugin hot reload, so a turn that ends during a reload still shows Done;
dispose publishes nothing, and a level the old generation could not deliver
is re-posted by the next. On reconnect it re-syncs blockers for the owned
sessions OpenCode would not re-sync itself, ignoring requests already
answered. Events are handled synchronously, so a slow post can no longer
hold up event processing. The server path, OpenCode 1 and mimo are
unchanged.

* fix(opencode): keep an OpenCode 2 pane on Needs input while its root streams text

Orca treats every OpenCode MessagePart as Working. While a background
subagent waits on a permission or form, the root session can keep streaming
reply text, and the TUI reporter forwarded that text as a MessagePart. The
pane then flipped from Needs input to Working, and nothing restored it until
the level changed, so the user could miss the open request.

Skip reply text while the pane's level is Needs input, as the reporter
already does for queued prompts.

* fix(opencode): leave OpenCode 2 step events to the server path's own change

The shared-server translation re-derived Working from session.step.started.
The pane reporter no longer uses it, so it only changed the server path and
duplicated a separate open change. Drop it; server behaviour matches main.

* fix(opencode): reset an OpenCode 2 pane to idle when its TUI starts

Before this change, a pane could keep a status an earlier process left on
it. The case that matters is an upgrade mid-turn: the old shared-service
plugin posted pane B's turn under pane A's key, then stood down, and pane
A's TUI loaded the new reporter owning nothing, so it never posted and A
showed a wrong Working until its own next turn.

A freshly started reporter that owns no running turn now posts the host's
existing session-start boundary once, which the host shows as connected
idle: no completion, no notification, and an unseen Done it lands on stays
unread. A plugin hot reload keeps its memory and skips it, and where
OpenCode keeps no plugin memory it is never sent.

* fix(opencode): keep OpenCode 1 serve + attach sessions on the attaching pane

OpenCode 1 `opencode serve` in one pane plus `opencode attach` in others
reports every session from the serve process, whose environment names only
the serve pane. Before this branch the session-to-pane binder moved those
posts to the attaching pane; deleting it for OpenCode 2 put them back on the
serve pane.

Restore the binder, registry, correlation and client sweep for OpenCode 1
(and mimo-code, which it also served), gated so OpenCode 2 never uses it:

- The status plugin now sends `opencodeMajor: 2` on every post from a process
  whose loader called setup(), which only OpenCode 2 does. The host skips the
  binder (no rewrite, no kicked round) for any post that carries it. OpenCode 1
  and older plugins send nothing and keep the previous behaviour.
- The binder reads only OpenCode 1's `session` table. OpenCode 2 writes
  `session_v2`, so its sessions never bind; on a database both versions wrote,
  OpenCode 1 sessions are no longer hidden behind the v2 table.

OpenCode-1-only; it goes when OpenCode 1 support is removed.

* fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane

OpenCode 2's `opencode run` loads no plugin, and the shared service that
runs its session cannot tell which pane it belongs to, so a pane running it
showed nothing.

The runtime now reports it from the pane's own process lifetime. On the
pane's OSC 133 command start (main already parses 133 for every local PTY;
the start callback was never wired), after the pane tracker's 350 ms settle
it reads the foreground process name through the existing foreground
reader, and only when that name is OpenCode, the foreground command line
from one fresh shell-foreground process-table capture. An `opencode run`
posts Working through the existing terminal-status path into the hook
server's store; the pane's 133;D (or the daemon's background fact) posts
Done, marked interrupted on Ctrl-C. No polling.

Exclusive with plugin reporting: each write carries the command's start
time, and the store drops it once a hook has written the pane since then,
so an OpenCode 1 `run` (in-process plugin) or a server plugin without the
TUI copy owns its command alone and there is never a second Done.

Local macOS and Linux panes only; SSH, WSL and Windows panes stay silent
because their foreground cannot be read on this host.

* fixup! fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane

Type the selected descendants as process-table rows; ReturnType of the generic collector widened them to bare identity rows.

* fix(opencode): keep an `opencode run` pane's Done after the command exits

The run's Done was published inside the chunk that carried its OSC 133;D,
before the chunk's command-finished fact. The renderer drops an exited
agent's row on command-finished when the row has not changed since that
fact arrived, so it took the Done as the stale row and dropped it, in the
renderer and in main.

Publish the run's Done after the chunk's side-effect facts are emitted (and
after the daemon's background fact). The renderer then sees command-finished
while the row is still Working, and the Done that follows counts as a change,
so it stays, the same way a hook Done that lands after exit already does.

* fix(opencode): let an `opencode run` revive a pane Orca retired

When an agent Orca launched exits, command completion retires the pane, and
only a hook new-turn event revived it. A later `opencode run` in that pane
posts no hook, so its process-lifetime Working was refused and the pane
stayed silent.

A process-lifetime Working is posted only after a fresh OSC 133;C and the
pane's own foreground argv prove a new OpenCode run, which is at least as
strong as a hook new turn. The store now revives the retired pane on it the
same way: it clears the retirement, drops the launch-token fence, and rebinds
the observation. OSC status and a lone process Done still cannot write a
retired pane, and a closed tab stays closed.

* fix(opencode): report `opencode run` on local Windows panes too

The run producer skipped every Windows pane, although Orca already resolves
a Windows pane's foreground agent from the native process table. On main an
OpenCode 2 `run` showed there (on the service's pane); on this branch it was
silent.

The argv read now has a Windows branch: one fresh native table read
(windows-process-table, no interpreter spawn), the same foreground identity
the Windows resolver already computes, and the command line of the process
that identity names. It runs only after the name read says OpenCode. Local
Windows panes whose shell prints OSC 133 C/D (PowerShell with PSReadLine,
Git Bash with Orca's wrapper) now go Working, then Done; cmd.exe prints no
markers and stays silent. SSH and WSL panes stay silent.

* fix(opencode): re-read an `opencode run` foreground on the pane tracker's ladder

The producer read the pane's foreground once, 350 ms after the command
started. A wrapper, a shim or `sleep 1; opencode run` execs OpenCode later,
so those runs stayed silent. The renderer's pane tracker already re-reads a
command's foreground at 350, then 1200, then 6000 ms for exactly this.

Move those delays to one shared module and use it from both. The producer
re-reads only while the foreground is a non-shell process that is not
OpenCode, and at most on those three rungs; no new polling.

* fix(opencode): end an armed `opencode run` when the next command starts

A new OSC 133;C in a pane whose `opencode run` was still armed dropped the
armed state without a Done, so a run whose 133;D never arrived left the pane
on Working. A new command start proves the previous command ended, so it now
posts that run's Done (not marked interrupted: no exit code is known) before
the new command is inspected.

* fix(opencode): skip the session-start row inside an OpenCode 1 `run` process

OpenCode 1 `run` loads the status plugin in its own process, and the plugin
posts SessionStart when the run's session is created. The host lands that as
an idle session boundary, so a pane the run producer had already shown as
Working blinked idle before the plugin's Busy.

The plugin now skips SessionStart when its own process is a `run`, read from
its argv the same way isOpenCodeRunCommand reads a pane's foreground (first
positional after global options, past the compiled binary's entry path). A
`run` session goes Busy at once, and its first prompt part still revives a
retired pane and resets the turn caches. The TUI and `serve` still post it,
and OpenCode 2's `run` loads no plugin.

* chore(opencode): say where the plugin's opencodeMajor comes from

The field is set when OpenCode's loader calls setup(), which only OpenCode 2
does; the plugin never reads OpenCode's version. Say so at the field. Comment
only; the generated plugin is unchanged.

* test(opencode): type the late-Done pane fixture without bare casts

The new renderer test passed its fixtures with `as never`; use the checked
fixture-tuple cast with a SAFETY note, like the sibling pty-connection tests.

* fix(opencode): keep `opencode run` silent when OpenCode status is turned off

The run producer ignored the status-hooks switch, so a user who turned
status off globally or for OpenCode (#23667) still got a run's Working and
Done. It now checks isAgentStatusHooksEnabledForAgent for the run's agent
(opencode or opencode2), the same predicate the plugin install honours,
before reading argv or posting anything.

* test(opencode): read the late-Done row without an untyped property access

The mock store types agentStatusByPaneKey values as unknown, so reading
`.state` failed tc:web. Assert the row with toMatchObject instead.

* perf(opencode): read a command's foreground only while it could become `opencode run`

Two costs the run producer paid on every command in every local pane:

- The retry ladder re-read the foreground (a process-table capture on the
  local provider) for any non-shell program still running, including other
  agents, editors and dev servers, up to three times. It now re-reads only
  while the foreground is still the shell (the command has not exec'd yet)
  or an unrecognised launcher that may still exec OpenCode (node, bun, bunx,
  npx, npm, pnpm, pnpx, yarn). Any other program is read once.
- With OpenCode status turned off for both opencode and opencode2, it still
  set the timer and read the foreground only to discard the result. It now
  returns before any timer or read. The per-agent check after the name read
  stays for the case where only one of them is off.

* fix(opencode): let a new hook turn end a pane's process-exit completion

A confirmed agent process exit records a pane-wide completion identity that
names only the agent. Hook Dones are matched against it by agent alone, and
only a working title cleared it, so in a pane whose agent paints no working
title (OpenCode) every later hook-reported Done was treated as already
notified: no notification and no unread mark. An unreported exit (a status-off
`opencode run`, or quitting an idle client) was enough to set it.

A fresh hook Working now clears a process-exit identity, since a new turn
cannot be a duplicate of an earlier exit. Hook identities stay, so
same-turn and replay dedupe is unchanged.

* refactor(opencode): share the foreground read schedule as one value

The pane tracker's import of the two shared foreground-read delays took
four lines where its old local constants took three, which put the file
one line over max-lines once merged with main. Export the settle delay
and retry ladder as one object so each consumer imports a single name.
No behaviour change: same 350 ms settle and 1200/6000 ms retries.
2026-09-30 15:37:18 -07:00
Brennan Benson 8dab53fb5a fix(worktrees): a finished create no longer pulls you off the workspace you switched to (#23974)
* fix(worktrees): a finished create no longer pulls you off the workspace you switched to

Show a ready toast with an Open action instead (#9944).

* fix(worktrees): a local agent create no longer opens its workspace from the host

For a composer create on a local repo that starts a terminal agent, main
spawned the agent terminal with `activate: true`. The renderer turned that
into a workspace switch before `createWorktree` resolved, which cleared the
creation panel pointer: a user who had moved to another workspace was still
pulled onto the new one, and every such create also showed a redundant
"ready" toast and lost its completion focus.

The host now adopts the startup and setup terminals silently
(`surfaceOwner: false`, no activation), the same way runtime-managed creates
do when not asked to activate. The submitting renderer's completion rule is
the only thing that decides whether to open the new workspace.

Also: the ready toast's Open is a deliberate user open (`navigationIntent:
'user-open'`), and the renderer tests now drive the composer's live entry
and reach the post-create cancel check.

* test(worktrees): pin split-mode setup silence and agent-tab focus on completion

- Pin that split-mode setup is split into the startup terminal without
  surfacing the new workspace.
- The watching-user completion test is now a real agent create: activation
  returns no primary tab, as it does in the app.
- New store-backed test drives the host's silent reveals through the real
  bridge, then the completion's activation and focus queue, and checks
  focus lands on the host-adopted agent tab rather than the setup tab.

* fix(worktrees): start a backgrounded create's renderer-owned startup without opening it

When the host does not start the agent (SSH repos, local repos with
project default tabs, VM creates) and the user has moved on, completion
seeds the agent/setup/issue terminals without activating them. Nothing
mounted those panes, so the agent, draft prompt and setup script waited
until the user opened the workspace while the toast already said it was
ready.

The non-activating branch now asks for a background mount of just the
created tabs that still owe startup work, the same request the terminal
bridges and sleeping-agent wake already use. Idle tabs and host-started
terminals are left unmounted.

* test(worktrees): pin background mount for setup-split and issue-split only tabs

* fix(worktrees): name the ready toast by kind and skip it for a user already there

- The toast now reads "Worktree <name> is ready" ("Workspace <name> is
  ready" for folder repos, which are not git worktrees) with a
  "Go to worktree" action led by the external-link icon. Old
  readyToast/openReady keys are replaced in all six catalogs.
- The created row is listed before completion, so a user can open the
  new workspace while it is still being created. Completion now treats
  that user like one still watching the creation panel (open + focus the
  agent, no toast), and the toast is also skipped if they opened it
  while a native chat was starting.

* fix(worktrees): drop the unreachable toast-time "already on it" re-check

Nothing between the completion decision and the toast yields to user
input (the structured-chat launch awaits nothing), so the re-check could
never fire, and a future await would have let a user who reopened the
panel get neither activation nor a toast. The decision-time check stays.
Also clarify why a folder workspace's title says Workspace while the
button keeps one label.

* fix(worktrees): folder workspace ready toast says Go to workspace
2026-09-30 15:15:23 -07:00
Brennan Benson b4b708c2c4 fix(native-chat): a Codex stream retry is one warning row that updates in place (#23684)
* fix(native-chat): a provider's own retry progress is quoted in its retry row

A retry row whose fact carries a detail the provider wrote for a person now
quotes it, the same way a rejected message or failed compaction does, so the
row says how the retry is going. A log detail still stays out of the sentence.

* fix(native-chat): a Codex stream retry is one warning row that updates in place

An error Codex says it will retry used to fall through to the generic frame
row: red, and a new row for every attempt. It now writes one providerRetrying
row per retry run, warning-toned, revised by each attempt with Codex's own
progress sentence. A run is the retry frames of one turn with nothing else the
thread journals between them; every attempt still publishes, so the idle sweep
keeps seeing activity. Errors Codex will not retry are unchanged.

* fix(native-chat): a Codex retry row says it is retrying and keeps the frame behind Details

The quoted retry sentence leads with "is retrying", which holds for any
provider's progress text. The Codex retry row also keeps the whole bounded
frame behind the row's Details, as the generic row did, so Codex's
additionalDetails stays available to diagnose a retry.

* test(native-chat): a Codex retry re-handled after backpressure keeps its one row

Pins the run being opened before the write: a first attempt whose publish is
refused and is handed back must revise the row it already wrote, not open a
second run. Also stops the fixture claiming Codex sends an idle thread status
beside each retry, which the app server does not do.

* perf(native-chat): a Codex frame with no retry run open is not classified

Ending a retry run classified every non-retry frame, and classifying walks the
whole payload: every streaming delta, and every large item/completed, paid a
walk about as costly as parsing the frame. Only a thread with a run open needs
the answer, so the classification now runs only there.

* fix(native-chat): each Codex retry attempt is its own warning row, with what failed on its second line

A stream error Codex says it will retry is written as its own warning row
with a providerRetrying fact, under the same per-frame identity every
Codex frame row gets. The host no longer tracks retry runs or rewrites
one row in place, so there is no run state to open, end or clear, and no
frame has to be classified to end a run. Every attempt publishes, which
keeps renewing the idle clock while Codex retries.

Codex's additionalDetails, which its own UI shows under the progress
message, is kept on the fact as the retry's cause and printed on the
row's second line. Errors Codex will not retry are unchanged.

* fix(native-chat): a transcript draws only the latest row of a provider retry run

The shared structured message projection, which both the desktop and the
mobile transcript read, collapses a run of retry rows into its latest
row. A run is retry rows from the same agent with no other drawn row
between them; a row that draws nothing, or a queued send drawn after the
conversation, does not split it. The earlier attempts stay in the
journal.

* test(native-chat): the retry-run render test uses the message list's current props

* fix(native-chat): a Codex frame row is named for its connection, so a later one never revises it

Frame rows were named provider-frame:codex:<n> from a counter that starts
over with every connection, so the first frame row after a reconnect in the
same session revised an earlier connection's row in place, at its old spot.
Each connection's frame rows now carry the acquisition generation, minted
once before the translator is built: provider-frame:codex:<generation>:<n>.
Rows already written keep their identities.

* fix(native-chat): agents retrying at once each keep one row, read from the row's own agent

* test(native-chat): a reconnect's Codex rows are named for the acquisition that received them

* fix(native-chat): a retry run is one agent's, so another agent's row never splits it

Each agent's rows are drawn apart: the session's own rows are the conversation,
and a subagent's rows open in that subagent's section. Splitting a run on any
other row in the flat list left two adjacent retry rows on screen whenever
another agent wrote between two attempts: a subagent finishing a command while
the session reconnected, or the session working while a subagent reconnected.

A run is now per agent: an agent's retry rows with none of its own other rows
between them, drawn as its latest.

* test(native-chat): a subagent's retry run is checked in its own section, and the run rule's words say same agent

* test(mobile): the retry-rows test typechecks, so the test ratchet keeps checking it
2026-09-30 15:05:22 -07:00
Brennan BensonandClaude 24edf0f64b fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm (#23467)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(agent-status): a turn a crash cut off reads Interrupted, an unproven end Couldn't confirm

When the provider gave no verdict, the structured status projection now derives
one from the newest turn's lifecycle: interrupted -> interruption, unverifiable ->
unconfirmed. Nothing new is journaled, the completion feed stays provider-only, and
the legacy interrupted flag stays a user stop only. Every verdict reader handles
both arms explicitly.

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

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

* fix(native-chat): a folded turn a crash cut off reads Interrupted after N

The settled-turn timing now carries the turn's verdict, derived by the same
agentTurnVerdict the status row uses. A turn that ended interrupted with no
provider verdict heads its fold 'Interrupted after N'; a user's stop keeps
'Worked for N'.

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

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

* fix(native-chat): the user's close of a chat records the turn it cuts short as their cancellation

The expected-close settle writes outcome cancellation when the user aimed the
stop at this chat: a Stop while the agent starts, the chat's tab closed (the
agentSession.close RPC, or session.tabs.close with a user reason), or /clear.
A quit, an idle eviction, a worktree teardown or an orchestration stop leaves the
turn with no verdict, so it still reads Interrupted.

* test(native-chat): a Claude turn a newer send superseded reads Interrupted

The supersede fires for any send Orca dispatched, the user's or another agent's,
and nothing at that site records the sender, so the turn keeps no verdict and
folds as Interrupted after N.

* fix(native-chat): the user's close records cancellation on the turn the provider settled on its way out

The Codex and Claude adapters settle their open turn as interrupted, with no
verdict, while the host stops them. The expected-close settle then found no
running turn, so a user's close of a mid-turn chat read Interrupted. The stop
now reads the running turns before it reaches the provider and records the
user's cancellation on each one it cut short, unless the provider gave a
verdict of its own. The close-verdict test's adapter now settles its turn on
close the way the real adapters do.

* test(native-chat): update the close and settled-turn expectations for the host-observed verdict

agentSession.close now passes the user's word to the host, and a settled
interrupted turn with no provider verdict carries `interruption`. Also merge a
duplicate import the code-quality gate rejects.

* refactor(native-chat): drop the composer's second error formatter

After the merge with main, every chat write in the composer path reports its
failure as a typed outcome worded by the refusal-notice table, so the send's
catch sees only a local throw. The {code, message} formatter this branch added
for it has no payload left to format, and its claim to be the one way a chat
words a failure is no longer true. The composer send is main's again.

* test(native-chat): pin the reason on a message rejected while its chat was closed

The reopen test checked only that the message reads as not sent; it now also
checks the Retry row carries the host's reason.

* fix(native-chat): the user's close cancels a turn whose start landed as the provider stopped

The close read which turns were running before the stop. A turn whose start was
still in flight (a send echo not yet journaled) was absent from that read, so the
provider's verdict-less settle on the way out left it Interrupted. The close now
reads which turns were already over instead, and records the user's cancellation
on every other turn the stop left running or interrupted with no verdict. A turn
cut off earlier, or finished during the stop, keeps its end.

* fix(native-chat): a send the provider never received after a restart has no verdict

Restart reconciliation rejects a crash-stranded send that is absent from a
trustworthy provider history with reason 'not_delivered'. Nobody failed that
send, but the verdict allowlist did not name it, so after a crash the chat
read Failed, was listed, and could notify "failed". Give the reason a shared
constant (persisted value unchanged), add it to the no-verdict set, and treat
it as an internal marker so the Retry row no longer shows the raw string.

* refactor(native-chat): the adapter settles the turn a stop cuts with the stop's typed cause

The host hands its stop's cause to the adapter's close. Each adapter settles its own
open turn on 'ended' through one mapping, turnVerdictForChildEnd, and the host's
dead-generation fallback uses the same mapping for any turn no adapter settled. A
user's close or stop of this chat is their cancellation; a quit, eviction, teardown
or an exit the adapter saw first is news.

Deletes the snapshot-and-diff reconstruction (endedTurnItemIds,
userStoppedTurnRevisions, settleUserStoppedTurns) and the requestedByUser flag.
host.close now takes a required cause.

* test(native-chat): a user's close drops the chat's status row like an eviction

* test(native-chat): expect the eviction cause on the host closes of idle release, worker stop and worker discard

The stop's typed cause now travels into host.close and the adapter's close, so these
three non-user closes assert the 'evict' they pass.

* refactor(native-chat): every stop names its cause, so none defaults to the user's cancellation

stopStructuredAgentSessionAgentUnderSerialize defaulted its ending to 'user-stop', which now
settles the cut turn as the user's cancellation. Every caller already passes a cause; the
parameter is now required, and a type-level test fails to compile if the default returns.

* test(native-chat): pin who a chat's session.tabs.close speaks for, older clients' reasonless close included

The mapping lived inline in a type-unchecked file, and only the explicit user reason had a test: an older client's reasonless close, or a lifecycle echo read as the user's, stayed green. It is now one exhaustive, type-checked function with a case per reason.

* style(mobile): draw the unconfirmed dot in the theme's status amber, not an inline hex

* docs(agent-status): the main agent's outcome also carries the host-observed end, interruption or unconfirmed

* fix(native-chat): a chat the user closed while its agent started is not a failed start

A still-starting child the user's close cut counted as a failed start, since only 'user-stop' was excluded: a start-failure row, and queued messages rejected as a provider failure. Whether an ending fails its start is now one exhaustive switch, shared by the failed-start read and the delivery loop's handover: a user's stop or close never does; an exit, a failed attach and the host's own stops still do.

* fix(native-chat): a chat the user closed closes its queued messages, and starts no agent for them

After 876b6989f1 a user's close of a still-starting chat went on like a Stop, so when the close did not complete the delivery loop started a new agent for the message queued behind it. A child's end now has three dispositions, not a failed-start boolean: a user's Stop lets the queue go on, the user's close closes what was queued before it, and any other end fails it. The close is the one a completed close does (the provider-closed rejection, no verdict), applied at the top of each delivery step and ordered against the close so a later send still goes on.

* fix(activity): a crash-cut turn draws the interrupted glyph; only a user's Stop keeps the done check

The Activity page drew every Interrupted row with the done check, which #2569 chose for a user's Stop. With a crash now reading Interrupted, that put a green check on a turn nobody asked to stop. The row's glyph is now an exhaustive switch over the verdict: a cancellation keeps the done check, and an interruption draws the existing interrupted dot. An unconfirmed end already drew its own glyph.

* fix(activity): the Interrupted group header draws the done check only when every row is a user's Stop

A user's Stop and a crash share the Interrupted group, and its header took its newest row's glyph, so a Stop newer than a crash put a green check over the crash. The header is now folded over the group's rows: the done check only when every row draws it, the interrupted dot otherwise.

* fix(native-chat): a failed close of what the user closed starts no agent for it

Rejecting the messages a user's close left queued swallowed a journal write failure, so the delivery step went on to start an agent for a message in a chat the user closed. The rejection now reports whether it landed, and a step whose rejection failed stops instead; the next wake re-derives and retries it. Also pins that the ordering against the close holds only within its epoch, since a later epoch's sequences restart.

* test(native-chat): the idle sweep's stop is an eviction, so its close carries that cause

Main's idle sweep now stops an idle agent through the conversation lifetime, which this branch gives the 'evict' cause; its expectations name it.

* fix(native-chat): a retried stop keeps the cause of the stop it finishes

A user's Stop or close whose wind-down failed after the child was proven gone was finished by the idle sweep as an eviction, so the turn it cut read Interrupted. The owed wind-down now carries its stop's cause, and a retry with no child settles with it.

* test(native-chat): the idle sweep's close of a retrying Claude chat carries the eviction cause

Main's new test expected the adapter close with the session id alone; every stop now names its cause, and the idle sweep's is 'evict'.

* fix(status): a user's Stop marks done on the tab and sidebar; red Interrupted is only a turn cut short by something else

The tab, the worktree card and the sidebar rows drew a Stop with the same red dot as a crash. The
verdict mark now maps a cancellation to done, still saying "Interrupted by user" in the row text,
and the mobile mirror follows. The Activity page keeps grouping a Stop under Interrupted with the
done check, as before.

* test(cross-version): a new phone reads a user's Stop as done; an old phone still draws it interrupted

* fix(native-chat): a user's Stop inside a live Claude chat reads as their cancellation

Stopping a running Claude turn interrupts it and keeps the session, so the turn's end comes from
the CLI's result frame. Claude CLIs before 2.1.91 send that frame with no terminal_reason, and later
ones may still omit it, so the user's own Stop was recorded as a failure with an error row.

Orca now records the stop on the open turn when it sends the interrupt. An error result for that
turn reads as the user's cancellation whatever reason the CLI gives. The stop belongs to that one
turn, so it cannot reach the next, and it is withdrawn when the CLI refuses the interrupt.

* docs(agent-status): a user's stop marks done; name the tab close cause by its type

The reference still said a stop marks a row interrupted and ranks between live work and an
unconfirmed end. A cancellation now marks done, and only a turn cut short by something else ranks
as interrupted. The runtime's tab close restated the close cause's union; it now uses the type.

* test(native-chat): a proven crash reads as an interruption on the status feed and in the chat

A crash the relaunch proves now settles its turn interrupted, and the status feed works the verdict
out from that record, so the restart test expects interruption for a proven crash and unconfirmed
for one it cannot prove, never a cancellation. A chat read before the proof lands reports
unconfirmed, then interruption and a folded "Interrupted after 27s" once the proof revises it.

* fix(status): a user's Stop reads Interrupted, and a turn anything else cut short reads Failed

The verdict mark now maps a cancellation, the user's own Stop, to interrupted, and an interruption,
a turn cut short by a crash or a killed agent, to failed, the same as a failure, which outranks live
subagent work. An unconfirmed end is unchanged. This applies to every agent, in a terminal or a chat,
on the tab, the sidebar rows and worktree card, the dashboard row, Cmd+J and the phone. A Stop is
not news, so the rollups rank it below an unconfirmed end, and notifications word an interruption
"failed". Recording is unchanged.

* fix(activity): group a user's Stop under Interrupted and a crash with failures

A user's Stop draws the interrupted glyph and sits alone in Interrupted, and a turn anything else cut
short sits in Failed, titled "Agent failed". Every row in a status group now draws the group's own
glyph, so the header is the group's status and the rule that folded a Stop's done check into the
header is gone. Interrupted ranks below an unconfirmed end, as in the sidebar.

* fix(status): draw a user's Stop in the muted tone, not the fault red

The interrupted dot, which now means only a user's Stop, draws in the muted foreground token on the
agent rows, the sidebar card and the phone. Red stays for a failure or a turn cut short by anything
else, and green for a finish.

* fix(native-chat): fold a stopped turn as "Interrupted after N" and a failed or crash-cut one as "Failed after N"

The settled turn header now follows the verdict mark: a user's Stop reads "Interrupted after N", and
a failure or a turn anything else cut short reads "Failed after N", under the new key
components.native-chat.status.failedAfter in all six catalogs and the boot catalog. Desktop and
phone share the one description, so they agree.

* docs(agent-status): describe the Interrupted and Failed marks

The reference and the phone's turn bar still described a user's stop as done and a crash as
interrupted. A fault now reads failed, a user's stop reads interrupted in the muted tone, and the
rollups rank an unconfirmed end above a stop.

* test(status): a crash the relaunch recovers marks failed

The recovery test still expected a recovered interruption to mark interrupted; it now marks failed,
as a failure does. Formatting only elsewhere.

* fix(native-chat): record a turn a newer request replaced as superseded, and show it Interrupted

A Claude turn that a newer send replaced before its result arrived was recorded as interrupted with
no verdict, which reads as a turn cut short by something else, now "Failed". It is now recorded with
its own outcome, `superseded`, where the replacement is detected. That outcome names no sender, so a
dispatch from another agent is never recorded as the user's Stop, and it sets no legacy flag.

Every reader handles it in an exhaustive switch: it draws the muted Interrupted mark with the plain
text "Interrupted", folds as "Interrupted after N", and attention demotes it with a Stop, through
the renamed agentTurnEndedOnRequest. Older builds read an arm they do not know as no verdict, which
is what this turn carried before, so their rows keep reading done; the cross-version suites pin an
older desktop's journal and status readers and an older phone.

* refactor(status): name the attention predicate for a turn ended on purpose

agentTurnEndedOnRequest becomes agentTurnEndedOnPurpose: a user's Stop or a newer request's
replacement, never a fault. The Claude turn-end comment no longer says a replaced turn carries no
verdict.

* test(native-chat): a turn cut off by a restart or by quitting Orca reads Failed after N

On main a restart-cut turn shows the done tick. Pin the chat's turn bar and
the tab's mark for both cuts, through the recovery settlement and the quit's
child-end mapping, and pin the quit's turn bar through the host's own quit.

* style(native-chat): format the superseded turn-bar expectations

* fix(claude): a Stop that names no turn is the user's stop of the open turn

The chat's Stop button names no turn. Claude's conversation Stop recorded the
user's stop only for a named turn, so an older CLI's error result after that
Stop read Failed. It now records it on the open turn through the same intent,
dropped when Claude refuses the interrupt and never carried to the next turn.

* fix(native-chat): keep the attach context's publishStatus required

The lifetime context type makes publishStatus optional, so the attach context
that spreads it no longer satisfied its own type once its duplicate
publishStatus went. The host's lifetime context is now inferred, and checked
with satisfies, so the spread carries the member it always sets.

* fix(activity): rank a user's Stop below live work in the status grouping

The Activity page's status grouping put the Interrupted group (a user's Stop, or a
turn a newer request replaced) above Working and Monitoring, so a Stop still sorted
like news there while the sidebar, worktree card and Cmd+J rank it below live work.
It now follows live work and stays above Done; Failed and Couldn't confirm keep
their places above live work.

* docs(agent-status): say which turn outcomes the journal records and which are derived

The journal now records superseded as well as the provider's verdict and a stop;
interruption and unconfirmed are derived on read. The resume row no longer claims
interrupted renders red.

* test(native-chat): the idle sweep's held-send rest closes with the evict cause, like its siblings

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-30 15:02:44 -07:00
Jinwoo Hong 84f58a1fc7 fix(relay): refuse a redial at once while the host's own release holds its row (#24225)
* fix(relay): refuse a redial at once while the host's own release holds its row

During an Asia drain the host whose socket closes is the one that redials.
Its release on the draining cell locks its assignment row first, then waits
on the cell's busy row for up to the lock timeout. The director's sticky
and placement paths waited on that assignment row inside the single sticky
slot, and the sticky path then locked the busy cell row itself before it
checked isolation. The slot backed up and dials timed out fleet-wide.

Both paths now take the host's assignment row NOWAIT and throw
RelayAssignmentRowBusyError when it is held. /v1/assign answers that with
503, Retry-After 1 and error assignment_row_busy, logged with its own
reason. The sticky path decides isolation before it touches the pinned
cell row. An isolated retry keeps its own tier as its retry scope, so a
busy lock inside it no longer falls to the all-rows path. The local drain
arm drops its zero-release-failures bar, which the Asia arm never had.

The drain harness gains a departing-host arm: each host releases its own
lease, then redials after 150, 400 or 1000 ms on the desktop client's
5-5.5 s pacing. At 400 ms, main rejected 83 of 180 first dials by sticky
wait timeout, placed 11.6/s with 3.1 director backends lock-waiting, and
took 11.2 s at p95 from release to placed. Now: 16 fast refusals, 18/s,
no lock waits, 5.7 s at p95.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): wait briefly for a calm host's row and keep the dead-cell sweep going

The dead-cell sweep treated RelayAssignmentRowBusyError as fatal, so one
busy host ended the sweep for every later host each tick. It now skips
that host and carries on.

The sticky path refused a busy row at once for every host. A calm host
redialling after its own clean close often meets its own short release,
and a refusal costs it the client's 5 s assign gate. When the pinned cell
is general and live, the sticky path now waits up to 1 s for the row
before refusing. A roll-isolated, parked or dead cell still gets the
immediate refusal. Placement keeps NOWAIT, because it holds cell rows
while it would wait. A resume refused for a busy row now carries
Retry-After 1 as well.

The departing-host harness arm now bounds the busy refusals at 20% of
hosts and the p95 at 8 s, and counts unexpected errors apart from
retryable refusals.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): never wait on a host's row while the sticky retry holds its cell row

The inventory-first sticky retry takes the pinned cell row before the
assignment row. With the calm-host bounded wait it could then wait up to
1 s on the assignment row while holding the cell row, the reverse of the
ranked lock order, against this host's own release, which holds its row
and wants the cell's. The bounded wait now applies only when no cell row
is held; the retry stays NOWAIT.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-30 17:58:30 -04:00
Brennan Benson 5cda0f4508 refactor(native-chat): keep agent-session records in the chat journal database (#24006)
* fix(native-chat): report a failed startup chat reconcile instead of failing app startup

At startup the chat host re-checks every saved chat's lease and writes the
result to agent-sessions.json. If that write failed (the file lock gave up,
the file could not be written, or the file was written by a newer Orca and
is read-only here), reconcileRestartLeases rejected, the startup IPC call
rejected, and the renderer fell into its degraded "Session restore failed.
Changes won't be saved until restart" mode.

The reconcile is bookkeeping: a lease left unreconciled grants no writer,
and every attach, send and read of a chat reconciles its own lease again.
So the startup reconcile now reports its failure through a new optional
host dependency, onStartupReconcileFailure, and resolves. The runtime
routes it to its onError sink under the scope
structured-agent-session-startup-reconcile, or logs it when no sink is
installed (the desktop installs none).

* fix(native-chat): read restored chats without waiting on lease bookkeeping

With native chat on and a chat tab open at quit, the renderer's startup
also awaits the chat tab restore (session.tabs.listAll). That restore
re-ran the lease reconcile before reading each chat and rethrew its store
failure, then recorded each restored tab as visible through a store
transaction that throws on a held lock or a read-only store. Either one
failed the restore, so startup still fell into "Session restore failed".

Reading a chat grants no writer, so the reconcile startup and the restore
run is now a reader's: createReaderReconcile never throws, answers whether
every lease is settled (recovery is resolved only then; the journal opens
either way), and reports each distinct failure once until a reconcile
settles. Attach and agent start keep the strict reconcile. The restore's
tab republish logs a failed visibility write and still publishes the tab,
since a client drops every unpublished chat tab; user-driven publishes
still refuse.

The host dependency is renamed onLeaseReconcileFailure (scope
structured-agent-session-lease-reconcile), since it now also reports for
reads.

* fix(native-chat): keep every record-store write off the startup chat read path

Round-2 review found two more writes on the startup chat restore that
could still fail it and put the app into "Session restore failed":
republishing a /clear replacement recorded its tab visibility strictly,
and resolving a chat's recovery rethrew its store error. The restore
also paid one lock wait per tab and per batch of chats while the lock
stayed held.

The restore now derives tabs from state it already holds:
- publishStructuredAgentSessionTab splits into the strict write and
  projectStructuredAgentSessionTab, which only updates the runtime's
  snapshot. The restore and /clear replacements only project: a saved
  tab index already lists every restored chat, and a /clear moves the
  tab in the same write that commits it. visibilityWriteMayFail is gone.
- Chats a legacy profile restores that the index does not list are
  recorded in one best-effort transaction (store.showSessionTabs), so a
  failure leaves the index absent to seed again rather than partial.
- The read restore's recovery resolution is caught and reported through
  onLeaseReconcileFailure, deduplicated with the reconcile's reports.
- Once lease bookkeeping fails in a restore pass, the rest of that pass
  skips it, so a held lock costs one wait for the startup reconcile and
  one for the restore, however many chats are open.

User actions (create, reveal, attach, send, the /clear commit) keep
their strict writes.

* test: open, seed and read the agent-session record store through one harness

Tests that open the durable agent-session record store, seed it, or read
back what it persisted now go through agent-session-record-store-test-harness.ts
instead of calling AgentSessionRecordStore.open or touching agent-sessions.json
themselves. A later change that moves the store into the chat database then
changes the harness instead of every test. No production code changes.

Tests whose subject is the JSON file itself (its .bak recovery, salvage,
schema versions, permissions, and what older builds read back) keep reading
and writing the file directly; the storage move rewrites or deletes them.

* fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure

The restore now runs one reader lease check for the pass and lets each chat
re-check and resolve recovery only while the pass is still settled. The
first refusal or failed write clears it for the rest of the pass, and every
chat is still opened for reading. With another process holding the lock,
startup waits on it once in prepare and once in the restore, however many
chats are open; a legacy profile waits once more for its tab-index seed.

* docs(native-chat): correct restore comments and a test name to match the final design

* test: address the record-store harness by the host's state directory

The harness took the store's own folder, so each caller picked one
(join(root, 'store'), or 'agent-sessions' where a test read the store the
runtime owns). A later change that moves the store into the state
directory's journal database could not tell those apart, and would have
had to edit every caller again.

Every harness function now takes the state directory, the one the test's
journal database and recovery capsule already live in, and keeps the
store in the same subfolder the runtime uses. Callers pass that directory;
store-only tests pass their temp directory unchanged. Format tests that
share a directory with harness calls take the file path from
testAgentSessionStoreFilePath.

The folder name moves from a private constant in the runtime to
AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness
shares it without importing the runtime. Its value and every path built
from it are unchanged.

* refactor(native-chat): keep agent-session records in the chat journal database

The record store's records, operation ledger, retired claim keys and chat tab
index become tables in agent-session-journal.db (user_version 4). The version-4
migration copies agent-sessions.json in its own transaction and never writes,
renames or deletes that file or its .bak. Each store write is one journal
transaction over exactly the rows it changed, checked with the load rules; the
file lock, the external-change refresh and its hash, the .bak rotation, salvage
and the hot-path recovery fence are gone from the store.

* wip: importer tests

* test(native-chat): cover the records migration, the import, row writes and read-only records

* docs(native-chat): retire comments that describe the records file as the live store

* test(native-chat): drop the record-store harness's leftover file path and type the import fixture

* test(native-chat): let the host harness cleanup wait out a recovery-offer read's lock

* fix(native-chat): let Stop reach the agent when its ledger row cannot be written

Stop's operation-ledger row now shares the database with the chat history, so
damage, a full disk or a stranded transaction on that write refused the Stop
before the interrupt. A cancel plan now takes its decision from the committed
ledger in memory, runs without settling, and warns that the row was skipped.
Other mutations answer proven damage with the typed "Unable to load this chat."
refusal instead of the raw SQLite error.

* fix(native-chat): answer whether a profile holds chats from the database's rows

Every host install creates agent-session-journal.db, chats or not, and the
version probe created it too, so its mere existence made every profile that
ever installed the host wait on host install and reconcile at startup. The
check now opens the database read-only and looks for a record or tab row,
lets the records file answer while its import is still owed, and counts an
unreadable database as present. The version probe no longer creates the file.

* fix(native-chat): open a chat from history when its tab index cannot be written

Over records a newer Orca wrote, every write is refused, so opening a closed
chat from Agent Session History failed on the tab-visibility write and the chat
read as unreachable. Like closing a tab, opening one now reports a failed
restore-index write and still publishes the tab.

* fix(native-chat): keep the records import owed when the backup read fails transiently

A torn records file whose .bak could not be read (EACCES, EIO) was reported as
unusable, so the migration completed with nothing copied and never retried.
A non-ENOENT read failure of either copy now carries its cause, which the
importer classifies as a read that can clear.

* test(native-chat): pin that an unreadable records file never falls back to its backup

* fix(native-chat): restore imported chats' tabs when the records file had no tab index

A chat created while the import was owed recorded a tab index holding only
itself. When the file it later imported had no index, that index still read
as recorded, so the imported chats' tabs never came back. The import now
clears the recorded marker in that case, and restore falls back to the
profile's tabs.

* refactor(native-chat): drop the unused in-transaction store write

Nothing called it, and it bypassed the write queue and the read-only refusal.

* docs(native-chat): say that an unusable records file is left untouched but never re-imported

* refactor(native-chat): keep the provider handle chain check as main has it

The chain-validation refactor has no measured need in this change.

* docs(native-chat): retire lease-renewer comments that describe the records file as the live store

* fix(native-chat): keep a throwing failure sink from failing the startup chat read

The lease bookkeeping failure reporter called the host's failure sink
directly, so a sink that threw turned a reported, recoverable store failure
back into a rejected startup reconcile or read restore. The reporter now
catches a sink throw and logs both the original failure and the sink error
with console.warn.

* test(native-chat): wait for a replaced host's restart-offer writes before cleanup

A restart test replaces the host without tearing the old one down, so the old
host's fire-and-forget restart-offer withdrawal could still hold the recovery
capsule's lock directory when cleanup removed the test directory (ENOTEMPTY).
The harness now hands hosts a capsule that tracks running operations and waits
for them before removing the directory, replacing the rm retries.

* docs(native-chat): retire the abandon helper's note that the store re-creates its directory

* fix(native-chat): restore a chat opened while the import was owed beside the profile's chats

When the imported records file had no tab index, restore fell back to the
profile's saved tabs, which never list a Claude chat, and the seed then
rewrote the tab table without the chat opened while the import was owed.
The tab rows that chat left are now loaded as unrecorded, restore takes them
together with the profile's chats, and the seed keeps their tab ids.

* test(native-chat): pin that a create whose tab index write fails still opens the chat

* docs(native-chat): say why restore puts chats opened while the import was owed first

* test(native-chat): replace a ledger row rather than change it in place in the Send-now rerun test

The record store freezes published rows in tests, so setting a row's outcome
in place threw; the test now swaps in a changed copy, as its sibling cases do.
2026-09-30 14:45:32 -07:00
Brennan Benson cfa43e7eab fix(codex): opening a terminal no longer strips Codex hooks from the real ~/.codex (#23552)
* fix(codex): a real-home restore leaves a file alone once someone else changed it

Orca writes ~/.codex/hooks.json (and a trust rebase writes config.toml), then
runs a Codex trust session for up to 10 s, then restores the original bytes if
the session fails. The restore wrote unconditionally, so a save that landed
during the session, from the user or another Orca, was silently reverted.

Each restore now compares first: it writes the original back only while the
file still holds the generation Orca's mutation left, and otherwise logs and
leaves it alone. This covers the real-home install and opt-out sweep
(restoreRealHomeHooksJson), the legacy sweep's hooks restore, and config.toml
rollback (restoreCodexTrustConfig).

For hooks.json the generation is the exact bytes Orca wrote. For a config.toml
that a trust rebase changed it is the file as the rebase left it. When Codex
itself wrote config.toml inside the session that just failed, Orca never knew
those bytes, so that rollback compares against the file as the session settled.
The next commit keeps other Orca instances out of that window; a user edit made
during such a session can still be rolled back.

* fix(codex): serialize real-home Codex writes across Orca instances

Every Orca on one HOME (a dev and a packaged app, or an offline CLI) writes the
same ~/.codex/hooks.json, config.toml and ~/.orca/agent-hooks/codex-hook.sh.
The per-file lane that orders capture, mutate and restore was in-process only,
so another instance could write inside this one's restore window, or undo it.

The lane for the user's real config.toml now also holds the existing
crash-safe managed-hook install lock (~/.orca/managed-hook-install.lock, the
one relay installers take for the same home). It is taken only by the
outermost acquire, because the lock file is not reentrant and grants and trust
rebases nest inside an install. Managed-home installs, the real-home install
and opt-out sweep, and the legacy sweep all enter through it. Compare-and-swap
on restore stays as the backstop.

A lock that cannot be taken within its 10 s wait fails that install, which is
already best effort: launch prep logs it, and the real-home lane falls back to
the managed lane until its retry.

* fix(codex): opening a terminal no longer strips the shared Codex entry from ~/.codex

Every Orca instance on one HOME writes the same status-hook entry into the
user's ~/.codex/hooks.json, with its trust in config.toml. Launch prep runs on
every pane spawn, and under a managed Codex account it ran the legacy system
sweep. That sweep matched Orca entries by script file name, so it removed the
current shared entry and the trust blocks the grant ledger recorded. On a live
laptop hooks.json went 4139 -> 18 bytes about 150 ms before a new pane opened.
With hooks off, the real-home lane's launch prep swept the same way.

Now nothing automatic removes the current entry or its trust:
- The legacy sweep removes only an enumerated list of retired command forms
  that no build writes any more (#1019's double-quoted form, #1536's
  exec-guarded form, and Windows' per-userData bare path), plus their trust.
- ensureRealHomeCodexHookState with hooks off writes nothing; that covers
  launch prep, session resume and startup.
- Only the user's explicit opt-out (codexHookService.remove()) strips the entry
  and its ledger-recorded trust from the real home.
- The sweep-suppression gate existed only to stop the sweep from deleting the
  current entry, so it is deleted with its main-process wiring.

Startup with hooks off already skipped the real-home install; with this change
the first pane's launch prep with hooks off also leaves ~/.codex untouched.

* fix(codex): a pane's prepare-codex only repairs a home its own HOME's app installed

On macOS a pane starts through login(1), so it gets the user's real HOME even
when its Orca app runs with another one. The pane's `codex()` preflight
installed hooks in the CLI process with that real HOME: it rewrote
~/.orca/agent-hooks/codex-hook.sh, promoted trust into the real config.toml,
and wrote the real HOME's script path into the app's managed home.

The preflight now acts only when the managed home's hooks already run this
process's own shared script, which proves the app that installed them shares
its HOME. Otherwise it writes nothing; the app installed the home at spawn.

Why not a no-op: the preflight was added (#14326) because trust can go stale
between opening a pane and typing `codex`, for example in a pane that survives
an app update, and Codex then stops in hook review. For a same-HOME pane it
still repairs that. Why keep promotion: the install drops runtime trust the
system config does not back, so skipping promotion would delete approvals the
user gave inside Orca-launched Codex.

* test(agent-hooks): await every installer in the refresher coverage test

The test fired each managed installer without awaiting it and read
~/.orca/agent-hooks straight after. Codex's install now takes the
cross-process real-home lock before it writes its script, so the script
landed after the read. Await the installers, and stub Codex's trust sessions
so the awaited install cannot start a real `codex app-server`.

* fix(codex): retire the two real-home command forms the list missed

The real-home lane wrote two Codex hook forms into ~/.codex that no build
writes any more and that the enumerated retired list did not name:
- POSIX, #9501 until #10885: the file-guarded form draining with a bare `cat`.
- Windows, #9501 until #10221 took Windows off the real-home lane: the encoded
  PowerShell launcher for a non-cmd-safe script path.

The file-name sweep removed both before; the enumerated sweep left them in
place, trusted, still passing the script's exit status to Codex. Both now
match as frozen literals.

Also corrects the startup ordering comment: the real-home install runs first
so its in-slot upgrade lands before the managed install's sweep retires the
prior command; nothing re-arms a legacy sweep any more.

* fix(codex): take the real-home lock only when a write is needed

The previous commit made every entry to the real-home config lane take the
cross-process lock. That lane runs on every pane spawn and every typed
`codex` preflight, so the steady state paid an owner probe (a `ps` spawn on
macOS) and could wait up to 10 s behind another instance's trust session,
even though it wrote nothing.

Each real-home writer now compares the desired state with the files on disk
first, without the lock. Only when a write is needed does it take the lock,
re-read and recheck, then write:
- real-home install: the planned hooks.json, the shared script and the
  ledger-recorded grant are compared; the locked path re-plans from disk.
- legacy sweep: locks only when a retired entry is present; the sweep re-reads.
- approval promotion: locks only when there is something to promote; the
  promotions are recomputed under the lock.
- the shared ~/.orca/agent-hooks script: locks only when its bytes differ.
The explicit opt-out always takes the lock. The lock is reentrant through
async context, since grants and rebases nest inside an install, so the
config-lane option the previous commit added is removed.

* fix(codex): a shared script without its exec bit is not the steady state

The compare-first check matched the shared ~/.orca/agent-hooks script on bytes
alone. writeManagedScript also restores 0755 on every call, and the POSIX hook
guard skips a script that is not executable, so a script whose mode was lost
(a dotfiles restore, a plain copy) now stayed that way: every Codex hook
drained stdin and reported nothing until an app restart refreshed the script.

The check now also requires the mode the writer sets, so that case takes the
lock and the write path repairs it.

* test(codex): the retired encoded launcher never matches today's shared one

The shared encoded Windows launcher is still current for other agents, so the
comment claiming today's launcher is never encoded was wrong. What keeps the
retired matcher off it is the exact payload: since #14825 the shared launcher
prefixes its payload and drops -ExecutionPolicy Bypass. Pin that with a case.

* fix(codex): the pane step recognises its own script under a home path with an apostrophe

The same-HOME check looked for the script path wrapped in bare single quotes,
but both hook writers escape an apostrophe inside the quotes. A home such as
C:\Users\O'Brien never matched, so the pane-step repair never ran there.

* fix(codex): the trust-RPC escape hatch still keeps the real home off its lane

The no-write check reported a recorded grant as current, so with
ORCA_DISABLE_CODEX_TRUST_RPC set the real-home lane stayed in use. The grant
itself refuses before reading its ledger; the check now does the same.

* fix(codex): the shared script write no longer waits on the real-home lock

The write is atomic and skips identical bytes; waiting behind another
instance's trust session could only fail a pane's managed-home install.

* fix(codex): an in-Orca approval survives a launch that cannot get the real-home lock

The install drops runtime trust the system config does not back, so a
promotion skipped for want of the lock lost the approval for good. It
now writes unlocked, as it did before the lock existed.

* refactor(codex): take the cross-process real-home lock back out

The lock fixed no observed failure. The three that were observed each have
their own fix in this series: the legacy sweep matches only frozen retired
command forms, hooks-off launch prep writes nothing, and a pane's
prepare-codex repairs only a home its own HOME's app installed. The lock
instead brought its own defects: a steady-state spawn waiting behind another
instance's trust session, a compare-first split to avoid that, a script
write and an approval promotion that could fail for want of the lock.

Removed, with their tests: the real-home write lock and its async-context
reentrancy, the plan/compare split that kept it off steady-state spawns, the
compare-first legacy sweep, the locked approval promotion and its unlocked
fallback, the compare-first shared script write (writeManagedScript already
skips identical bytes and restores the exec bit), and the CLI tsconfig
entries the lock pulled in.

Kept: the retired-forms matcher, the hooks-off no-op, removal only on an
explicit opt-out, the pane own-script check, and the compare-and-swap
rollbacks. Every instance now writes identical bytes idempotently.

* fix(codex): an opt-out that cannot read hooks.json keeps Orca's trust and ledger

The opt-out swept the real-home entry, then dropped Orca's ledger-proven trust
whenever a ledger existed, even when the sweep could not read hooks.json. The
entry could still be there, now untrusted, and the ledger that proves ownership
was gone for the retry. Drop that trust only after a sweep that read the file.

* refactor(agent-hooks): one predicate for whether an agent's status hooks are on

"Global switch on and this agent not turned off" was spelled out separately
in the startup controls, the settings reconcile, the retained-home
reconcile, the WSL preflight RPC, the CLI preflight and the OpenCode plugin
selection. They now share one function, in a module light enough for the
CLI's per-launch Codex preflight to load. The PTY spawn env derives the
Codex flag from the switch and opt-out list it already carries, the same way
it does for OpenCode and Pi, instead of receiving a second copy.

* fix(codex): launch and resume prep honour Codex's per-agent hook opt-out

Turning Codex off in the per-agent hook settings removes Orca's Codex hook
entry, but launch prep and session resume read only the global hooks switch,
so the next Codex launch or resume wrote the entry straight back into the
real ~/.codex or the account's home. Both now read the per-agent predicate,
which the PTY spawn env and startup already honoured.

* fix(codex): turning Codex off per agent clears the real ~/.codex entry

While the real-home lane owns ~/.codex/hooks.json, the legacy system-home
sweep stands down. That gate read only the global switch, so turning Codex
off per agent ran remove() with the sweep still suppressed and left Orca's
entry in the real ~/.codex. The gate now reads the per-agent predicate, the
same as turning every hook off.

* test(codex): cover the system ~/.codex sweep gate for Codex turned off

The gate that lets the legacy system-home sweep run was an inline closure in
startup, so reverting it to the global switch left CI green. It is now a
pure function beside the gate it feeds, with a table test and a remove()
test on a seeded ~/.codex: turning Codex off strips Orca's entry and keeps
user hooks; with Codex on the entry stays.

* fix(cli): keep the agent-status hooks predicate loadable by the packaged CLI

The CLI's prepare-codex handler imported the predicate from src/main, but
the Electron build rebuilds out/main from its declared entries only, so the
packaged `orca agent hooks` commands could not load it (package jobs and the
CLI bundle-parity test were red). The predicate reads only settings, so it
now lives in src/shared, which the CLI compiles itself.

* feat(codex): every Orca build writes one frozen Codex hook command

The Codex hook command was built from this build's wrapper, so two builds
on one HOME disagreed about the bytes of the shared ~/.codex entry and kept
rewriting it, with a Codex trust session each time.

The command is now fixed per form and carries its form number:
- POSIX: one command with no path in it. It runs the shared script only in
  an Orca pane with hooks on (pane key and hook port set), drains stdin
  everywhere else, and always exits 0. A branch for a per-build script root
  is written now and stays dormant until Orca sets ORCA_AGENT_HOOK_ROOT, so
  that change will not move these bytes.
- Windows: the bare forward-slash path to the shared .cmd, which runs under
  PowerShell 7 and 5.1, Codex's hook hosts. A profile path that is not one
  PowerShell token gets a plain PowerShell form with the same branches.

The literals live in the form module, so a change to the shared hook
constants cannot move them; goldens pin the bytes. Every form keeps
`agent-hooks/codex-hook.*` in plain text, so older builds still recognize it.

* fix(codex): one main-process owner adds the real-home entry; nothing restores files

Each Orca writer of ~/.codex decided what Orca's entry must be from its own
build and instance, then removed or reverted whatever differed: launch prep
rewrote any Orca-shaped entry to this build's command and stripped Orca
entries from events this build does not use, and a failed trust session
restored hooks.json and config.toml from snapshots. With several instances
and builds on one HOME, every disagreement became a deletion or a revert.

The main process is now the one writer, and its writes are add-only:
- A launch or resume adds Orca's frozen entry to an event that has none and
  leaves every Orca entry it finds, so a running older build is never fought.
- App start also converts an older Orca form to the frozen command, once, in
  its own slot: one hooks.json write (one .bak) and one trust grant per home.
- A newer form is never rewritten or appended beside, and Orca entries in
  events this build does not use are kept.
- After a failed trust grant, only an entry this call wrote that is still
  untrusted is withdrawn, putting back the handler it replaced. Both files
  are re-read, so a concurrent edit, or the identical entry another Orca
  trusted meanwhile, survives.

Deleted: the compare-and-swap hooks.json restore, the config.toml snapshot
restore after a grant session and after a user-trust re-key, and the
rollback module. A grant session writes trust only at Orca's own keys, and
every caller settles those keys itself. A failed re-key of moved user hooks
now keeps the write and reports it; Codex lists those hooks for review.

* fix(codex): the pane CLI asks the app to prepare its Codex home

`orca agent hooks prepare-codex` ran Codex's install inside the pane. That
process can have the real HOME (login(1)) and runs outside the app's
in-process queues, so it was a second writer of ~/.codex and ~/.orca beside
the app. A check that the home ran "its own script" guarded it.

The pane step now only asks the app, over the same kind of local RPC the WSL
pane step already uses (agentHooks.prepareCodexForPane). The app checks that
the pane's CODEX_HOME is one its own userData owns, reads its own hooks
setting, and installs on its own queue. An app that is not running, or is
too old to know the method, makes the step a no-op, as it is on WSL. The
own-script check and the CLI's settings read are gone, and the preflight
module leaves the CLI bundle.

* fix(codex): delete the pane step on native hosts

The previous commit had `orca agent hooks prepare-codex` ask the app to
prepare the pane's Codex home. The case it existed for (#14326, a pane that
survives an app update with stale hook trust) did not reproduce, and no other
desktop agent host writes agent config from a terminal or launch wrapper.

- Deleted: the agentHooks.prepareCodexForPane RPC method, its params and
  catalog entry, and prepareManagedCodexHomeBeforeShellLaunch with its module,
  tests and CLI build entry.
- `agent hooks prepare-codex` is a no-op on native hosts. It stays for one
  release so shell wrappers from older builds, which still call it, exit 0.
- WSL panes are unchanged: they still ask the app over
  agentHooks.prepareCodexForWslPane.

The shell wrappers and ORCA_CODEX_LAUNCH_PREFLIGHT stay, because WSL panes
use the same wrappers and variable (forwarded through WSLENV). A native pane
still starts the CLI once per `codex` it runs; skipping that is a follow-up.

* test(codex): a failed trust session keeps concurrent edits to both files

QA case 9 at host level, on a real file system in a temp HOME: Codex's trust
session fails after another writer saved hooks.json and config.toml.

- Both saves survive, and no Orca entry is left that Codex would list for
  review: this call's entry is withdrawn.
- A failed one-time conversion puts the older Orca entry back in its slot and
  keeps both saves.

Both tests fail on the previous head, which restored config.toml from a
snapshot and left the untrusted entries in hooks.json. Removing the
withdrawal turns both red.

* feat(codex): read whether an Orca entry's stored trust is still current

A Codex release that changes how it hashes a hook leaves Orca's stored trust
stale: the entry is present, but Codex lists it as modified. Checking only
whether the entry is missing cannot see that.

readOrcaEntryTrust sorts a present entry into four states:
- trusted: the stored hash is the current one;
- untrusted: there is no stored hash;
- stale: the stored hash is not the current one;
- disabled: the user turned the entry off.

The caller can pass Codex's current hash, for example one a grant recorded.
The failed-grant withdrawal now uses it, and also keeps an entry the user
turned off. Nothing re-grants on 'stale' yet.

* fix(codex): a slow Codex start retries on the next launch, never for minutes

On a loaded Mac a cold `codex app-server` took over 10 s (QA case 4). The
grant timed out, the entry was withdrawn, and a 5-minute cooldown in both the
grant and the real-home install then refused every retry.

- The native session deadline is 30 s, the same as WSL's.
- A timeout starts no cooldown in the grant or in the real-home install. The
  next launch retries. Other failures keep their cooldown.
- Launches that queue behind a slow session share one follow-up run, so a
  launch waits for at most two sessions, not one per earlier launch.

Tests: a 15 s cold start still grants and keeps the entry; after a timeout,
the next launch runs a session at once; four queued launches run two
sessions. Each is red on the previous head, and each mechanism was removed in
turn to confirm its test turns red.

* fix(codex): Orca's automatic writes never move a user hook

Codex keys a hook's trust by its position in hooks.json. App start's collapse
of Orca duplicates removed every Orca entry and appended one at the end. That
moved any user hook that followed a removed entry, so the write waited on a
session to re-key the moved hook's trust.

App start now:
- converts the first Orca entry that sits in a plain slot to the frozen
  command, in place;
- drops any other Orca entry only when that moves no user hook;
- keeps a duplicate that a user hook follows, and trusts every frozen copy,
  so none is listed for review;
- appends only when no frozen entry is left.

Tests check user positions and user trust blocks byte-for-byte for each
automatic write: add-missing (append), the one-time conversion (in place),
a trailing duplicate, a duplicate before a user hook, and older duplicates
normalized to one entry. The three collapse cases fail on the previous head.
Removing the position check, or the in-place conversion, turns its tests red.
Only the explicit opt-out still removes an entry that user hooks follow.

* fix(codex): removing an Orca entry never waits on a Codex session

Removing an Orca entry from ~/.codex/hooks.json moves every user hook behind
it up a slot, and Codex keys trust by slot. The retired-form sweep, the
opt-out and a failed-grant withdrawal all asked a `codex app-server` session
to list the old trust before writing, and to re-key it afterwards. A timeout
there threw before the write and latched a 5-minute cooldown, so a slow cold
start blocked the retired-form sweep at boot (QA case 4).

Each moved hook's [hooks.state] block now moves to its new key, body bytes
unchanged, straight after the hooks.json write. Codex hashes a hook's content,
not its position or its file path, so the moved block stays exactly as valid
as it was: a trusted hook stays trusted, an untrusted one stays untrusted, and
one the user turned off stays off. No removal waits on or depends on a
session. A failed config.toml write keeps the hooks write and logs.

Deleted: the inspect and repair sessions, their client, and their cooldown.
The generation guards on the hooks.json writes stay, for other processes.

Tests: the retired sweep removes the retired entry and carries the trust of
the user hook behind it while every Codex session times out (red on the
previous head); the opt-out carries an appended user hook's trust; the move
carries trusted, disabled and untrusted states byte for byte. Removing the
move turns all of them red.

* fix(codex): a Codex launch never waits on Codex's approval of Orca's entry

A launch on the real-home lane awaited Codex's trust grant for the entry it
had just added. A cold `codex app-server` on a loaded Mac took over 10 s, so
the launch could wait that long, and a failure then latched a 5-minute
cooldown.

- Codex's approval runs in the background, with a 30 s cold-start budget.
- A launch uses the real home only when the ledger shows trust is already
  current. Otherwise it goes to the managed home at once, and the next launch
  picks up the finished grant.
- A launch that arrives while a grant runs does no work and does not queue
  behind it.
- A resume into the real home has no managed home to fall back to. It waits
  for the grant, but no longer than the 10 s a launch always could.
- A background grant that times out starts no cooldown; the next launch
  retries. Any other failure backs off for 10 s instead of 5 minutes.
  Success is what the ledger remembers.
- A failed grant still withdraws only what that install added and is still
  unapproved. The log now says how many entries it took back and when the
  next try comes.

Managed-home grants keep their 10 s deadline and stay on launch prep, as
before; they fall back to Orca-computed trust.

Tests:
- A 15 s start: the launch returns in under a second on the managed home, a
  second launch starts no session, the grant lands in the background, and the
  next launch uses the real home.
- A timeout sets no cooldown, withdraws its adds and logs it.
- Another failure retries after 10 s, not before.
- A resume waits only as long as allowed.
- Case 9 checks the log line and the retry.

Making the launch await the grant, a 10 s budget, either timeout cooldown, and
a 5-minute backoff were each tried, and each turns its test red.

* fix(codex): move a hook's trust only when every stored key has the known shape

Orca now edits Codex's trust store directly when a removal moves a user
hook. Three safeguards keep that honest:

- Fail safe. If any [hooks.state] key in config.toml does not have the
  shape `<path>:<event>:<group>:<handler>`, nothing moves and Codex asks the
  user to review. That shape was checked unchanged from Codex 0.141 to 0.158.
- Targeted. The file is read immediately before the atomic rename, and only
  the moved keys' blocks change. Every other byte stays, and no snapshot is
  restored.
- Verbatim. Each block's body moves as Codex wrote it, including fields
  Orca does not know. No hash is ever computed, and a hook with no block
  gets none.

Tests:
- An unknown key shape stops every move.
- Everything except the moved block survives byte for byte, and the moved
  body keeps an unknown field.
- In case 9, a hook the user approved during the failed session keeps its
  approval when the withdrawal moves it, beside the concurrent project edit.

Removing the shape check, or writing a computed block instead of the stored
body, turns these tests red.

* refactor(codex): keep only the trust read the failed-grant withdrawal uses

A capture across Codex 0.141, 0.150 and 0.158, switching in all six
directions, showed Orca's entry keeps the same hash and stays trusted. A
Codex upgrade does not make its trust stale, so nothing needs to re-grant
on staleness.

readOrcaEntryTrust keeps the four states the withdrawal needs, but loses
the parameter that let a caller pass a different current hash, and the test
for a Codex that hashes differently.

* fix(codex): native panes no longer start the Orca CLI before each codex

The pane step is a no-op on native hosts, but native panes still carried
ORCA_CODEX_LAUNCH_PREFLIGHT, so every `codex` typed in a pane started the
Orca CLI for nothing. Only a packaged Windows build's WSL pane now gets the
variable; the app prepares every native Codex home itself.

The resolver loses the dev-launcher path and its userDataPath option, which
only native panes used.

Tests: a native macOS, Linux and Windows pane gets no preflight, packaged or
not, even with the bundled CLI present; a WSL pane still gets the verified
absolute launcher. Letting native panes through again turns them red.

* chore(cli): say when the native prepare-codex no-op can go

Native pane wrappers from builds up to v1.4.216 still call it. It can be
deleted once no supported build's wrapper does.

* test(codex): check the WSL launcher path instead of asserting it

* fix(codex): a launch no longer waits behind the background real-home approval

The background grant ran its whole codex app-server session inside the shared
~/.codex/config.toml lane, and on a cold host its session was also the shared
capability probe. A launch sent to the managed home then waited on both: the
managed install and the project-trust write queue on that lane, and the
managed install's own grant waited for the probe. On a cold app-server that
was up to 30 s per launch.

The lane was held across the session only to protect the retired
capture-and-restore. Codex writes its own records, so the lane is now taken
only around Orca's own pre-grant write. The background grant runs its session
without publishing it as the shared probe, and the whole grant is bounded by
its deadline, so a hang outside the session cannot leave the lane 'granting'.

* fix(codex): a failed re-grant no longer strips Codex's own approval of Orca's entries

Before each trust session, the grant deleted every Orca record whose hash
matched the one Orca computes. That exists because a managed home's fallback
writes Orca-computed trust under both Windows path-separator spellings, and
Codex rewrites only its own spelling, so the other copy would linger. On
failure the managed and WSL fallbacks write that trust back, and before this
fold a snapshot restore covered it.

The real ~/.codex has neither: Orca never writes computed trust there (the
real-home lane does not run on Windows at all), so a matching record there is
Codex's own approval. After a ledger miss (another Orca profile, a Codex
update, a lost ledger) and a failed session, nothing put it back, and every
Orca entry showed "Hooks need review".

The clear now runs only for homes whose fallback writes that trust.

* fix(codex): a real-home resume spawns only once Orca's entry is approved or withdrawn

A resume that must run in ~/.codex waited at most 10 s for the background
approval, then spawned anyway. On a cold app-server that left Codex beside an
unapproved Orca entry, so the resumed pane showed hook review.

The resume now waits for the grant to settle. Settled means Codex approved the
entry, or the grant failed and withdrew its own unapproved write; the grant's
deadline bounds the wait (30 s, the cold-start budget), and a failed approval
never fails the resume.

Why this over the alternatives:
- Spawning at 10 s keeps the review prompt this fold exists to remove.
- Withdrawing at 10 s from the resume races the still-running session: Codex
  can write the frozen entry's hash after the withdrawal, and for a converted
  entry that marks the older command Orca put back as modified.
- A resume cannot use the managed home: the session lives in ~/.codex.
So the only states that cannot race Codex are the grant's own settle. The cost
is a longer worst case on a cold app-server (up to the 30 s deadline, plus any
managed-home install that holds the config.toml lane); a warm approval takes
seconds, and an approved entry costs no wait.

* fix(codex): keep the 5-minute trust cooldown for launch-path grants

The fold shortened the host's trust-grant cooldown from 5 minutes to 10
seconds for every grant. That was meant for the background ~/.codex approval,
which blocks no launch. The managed-home and WSL grants run inline on the
launch path, so with a hung app-server every launch more than 10 s after the
last failure paid the full inline timeout again (10 s native, 30 s WSL).

Cooldowns are now kept per lane: inline grants keep 5 minutes, the background
grant retries after 10 s, and neither lane's failure cools the other down. A
success, or a proven-missing surface, still clears both. The real-home
install's own retries (an unreadable hooks.json, unknown keys) are back on the
5-minute interval they had before the fold.

The cooldown moves to its own module so the grant stays within the file limit.

* fix(codex): a failed grant withdraws the exact copy it wrote

The withdrawal re-found "this call's" entry by command, taking the first
frozen handler in the event. When app start converted a later slot while an
earlier frozen copy sat in a matcher group (which conversion skips), a failed
grant acted on that earlier copy: it put the older command into it, or skipped
it, and left the converted, unapproved copy in place.

Each write now records where its handler landed, after any duplicate drops,
and the withdrawal acts only on that slot. A copy that has since moved is left
alone; the next launch's grant retries it.

* fix(codex): the failed-grant withdrawal checks hooks.json is unchanged before writing

The install and the retired-form sweep both refuse to replace ~/.codex/hooks.json
if it changed since they read it. The withdrawal did not: a save landing
between its read and its atomic replace was lost. The window is small, since
the withdrawal is synchronous, but it now carries the same guard.

* refactor(codex): drop rationale left over from the snapshot restore; name the trust-move module for what it does

Comments on the config.toml lanes still justified them by a grant's
capture-and-restore window, which the fold deleted, and the trust-write
deadline still counted a grant session holding the lane. They now give the
reason that remains: Orca's own multi-step reads and writes, and managed-home
installs that hold the lane across their inline grant.

codex-user-hook-trust-rebase no longer rebases through Codex; it moves stored
trust records, so it is now codex-user-hook-trust-moves.

The grant test that pinned two sessions on one config.toml to run one at a
time is removed: its reason was an interleaved capture and restore. Callers
that write config.toml around a grant hold their own lane, which the nested
installer test still covers.

* build(cli): list the trust-grant cooldown module in the CLI program

The CLI's agent-hooks handler loads the hook controls, which reach the Codex
trust grant; the CLI project is composite, so every module in that graph must
be listed.

* docs(codex): say which Windows hosts each hook command form runs under

Codex runs a hook under the turn's shell (PowerShell 7 or 5.1 in every
captured session) and, with no single local turn shell, under %COMSPEC% /C.
The bare forward-slash path ran under all three in the Windows host census.
The PowerShell form used for a profile path with a space does not parse under
cmd.exe; no form valid in all three hosts has been run for such a path, so the
form stays and the gap is stated here and in the PR.

* test(codex): type the withdrawal seam without an assertion

* fix(codex): a real-home resume starts at once, trusting Orca's entries for that process

A resume that must run in ~/.codex waited for Codex's background approval of
Orca's newly written hook entry: up to 30-40 s on a cold app-server. That made
the user's resume wait on bookkeeping, and the alternatives (start at 10 s with
Codex's hook review showing, or withdraw the entry and race Codex's own write)
were worse.

Codex reads hook trust from its session-flag config layer as well as the user's
config.toml, merged per key, and has since hook trust shipped. So the resume no
longer waits. When Orca's own frozen entries in ~/.codex are untrusted (or hold
a stale hash), the resume command carries
`-c hooks.state={'<key>'={trusted_hash='<hash>'},...}` for exactly those entries:
the key under both the logical and the real path of ~/.codex (Codex keys an
explicit CODEX_HOME by its real path), and the hash of that entry's content, so
it can trust nothing else at that slot. The user's hooks are never included,
nothing is written, and the background approval still runs for later plain
`codex` launches. An approved entry adds nothing; a Codex known to lack hook
trust gets nothing.

One inline table, because Codex splits a `-c` key on every `.` and the key holds
`.codex/hooks.json`. TOML literal strings keep `"` out of Windows native-argument
quoting. The flag goes before `resume <id>`, quoted for the pane's shell (portable
Unix, PowerShell or cmd), in the launch command and in the setup-sequenced copy of
it; a cmd line whose path cmd would expand, or a key with an apostrophe, is left
unchanged. SSH and WSL resumes get no preparation, so no local path reaches them.

* Revert "fix(codex): a real-home resume starts at once, trusting Orca's entries for that process"

This reverts commit 1bd30651d6.

* fix(codex): a real-home resume starts at once, without waiting for approval

A resume into the real ~/.codex waited until the background approval settled,
up to its 30 s deadline on a cold app-server: bookkeeping for later launches
gating the resume the user asked for. It now starts at once. If the approval is
still running, that first resume can show Codex's hook review once; the
approval then lands and later resumes and plain codex launches are trusted.

Trusting Orca's entries per process was the alternative, but the resume command
is typed into the pane's shell, and hook settings stay out of typed commands.

* test(codex): read real-home hook groups with the installer's own type

* fix(codex): a background approval is bounded only by its session's own deadline

Review loop 2, L3. grantWithinDeadline raced a second 30 s timer against
the background approval. Loop 1 added it so that a hang upstream of the
session could not leave the lane 'granting' forever.

That hang cannot happen. The only caller is the native real-home grant
(its plan is always host 'native'; the real-home lane is off on Windows,
so WSL never reaches it). Everything before the session is synchronous
there: command resolution and binary stamp, the ledger read, the
state-db backfill check, the capability and cooldown checks, and
runUnshared awaits no shared probe. A synchronous hang would freeze the
main thread, which no timer can rescue. The session itself starts a kill
timer right after spawn (runCodexAppServerSession), with the same 30 s,
and it kills the app-server tree when it fires.

So the outer timer was a second copy of that bound. Because it started
first, it won by the spawn time. It then settled the lane and cleared
backgroundGrant while the app-server was still alive, and the next
launch could start a second concurrent session. It abandoned the
session rather than cancelling it. Deleted, not moved: the session's
own timer is the one bound, and it cancels.

Test: codex-real-home-slow-app-server.test.ts "runs one session at a
time, ended by its own deadline". The fake session starts its timer
after a simulated spawn, as the real one does. A launch at 30 s finds
the session still running and starts none; the lane settles when the
session times out. It replaces the "settles a grant that never answers"
test, whose never-answering session could not time out at all.

* fix(codex): a background approval's retry has one schedule, the real-home lane's

Review loop 2, L4. A non-timeout background failure set two 10 s
schedules for one failure: the real-home lane's installRetryAfterMs,
which gates ensure, and a `<host>#background` cooldown in the grant
module. ensure's gate always tripped first, so the second one was
consulted only after something reset the first (turning hooks off).
Then it answered 'retry-cached', which wrote the entry into
~/.codex/hooks.json only to withdraw it again: churn, not protection.

Background plans now neither start nor consult a grant-module cooldown.
The real-home lane (installRetryAfterMs) is the one source of truth for
when a background approval runs again, and its 10 s interval moves into
codex-real-home-background-grant.ts, the module that sets it. The
cooldown module is back to one host-keyed map for launch-path grants,
with the same 5-minute interval as main. A success or a proven-missing
surface from either lane still clears the host's cooldown.

Tests:
- codex-hook-trust-grant.test.ts "neither starts nor waits on a
  cooldown for a background grant": two failing background grants each
  run a session and leave no cooldown; an inline failure still cools
  down inline grants and not the background one.
- codex-real-home-slow-app-server.test.ts "has one retry schedule:
  turning hooks off and on after a failure retries at once": after a
  failed approval, hooks off then on runs a session and installs,
  instead of a retry-cached write-and-withdraw.

* fix(codex): hooks turned off and on during an approval re-add Orca's entry

Review loop 2, L1. ensure returned at once whenever a background
approval was running, whatever the lane. Turning hooks off during an
approval sets the lane to 'removed' (usable), so turning them back on
returned 'removed' without re-adding the entry. Launches in that window
spawned in ~/.codex with no Orca hook and got no status for their
lifetime, for up to 30 s, until the approval settled and a later launch
re-added it.

ensure now returns early only while the lane is 'granting', which is
what the early return exists for: a launch never waits on Codex's
approval and uses the managed home until it lands. Any other lane runs
the normal add-missing install.

That install can start a second approval while the first is still
running. Approvals are now chained, so Codex still runs one session at
a time, and a finished approval clears the handle only if it is still
the latest one (before, an older approval's finally could clear a newer
one's handle). The older approval's result is already dropped by the
lane generation check.

Test: codex-real-home-slow-app-server.test.ts "re-adds the entry when
hooks go off and on during an approval, one session at a time". While
the approval hangs: opt-out removes the entry; re-enable re-adds every
entry, keeps launches on the managed home, and starts no second
session; once Codex answers, the lane is installed and every entry is
approved.

* test(codex): a launch during the real-home approval shows what it waits on

Review loop 2, M2. The launch test's fake Codex failed every
managed-home session at once with ENOENT, so the managed home's own
approval was an instant "unsupported" fallback, and the test could not
show that a launch sent to the managed home still waits on that home's
inline approval when its ledger misses (first use, a Codex update, a
lost ledger), up to 10 s, as on main.

Now the managed-home session behaves like a real one:
- "settles on the managed home with its hooks and the project trust
  written": the managed app-server answers; two launches settle in
  under 2 s while the real-home approval hangs, and the second launch
  finds the managed approval in its ledger (one managed session).
- new "waits up to the managed home's own 10 s approval when that home
  is cold too": the managed session fails at its own deadline, as the
  real one does. The first launch is still pending at 9.999 s and
  settles on the managed home at 10 s; the request asked for 10 s. The
  next launch settles at once, because the failed inline approval cools
  down for 5 minutes.

No product change.

* refactor(codex): the managed and WSL installs own their pre-approval trust clear

Review loop 2, L7. Before a Codex approval session, a managed or WSL home
clears the approvals Orca itself computed, because on Windows its
fallback writes them under both path spellings and Codex's canonical key
may not overwrite the other one. The fallback writes them back if the
session fails. ~/.codex has no such fallback, so there the clear would
only delete Codex's own records (loop-1 H2). The grant module carried
this as a plan flag, fallbackWritesSelfComputedTrust, and took the
config.toml lane around the clear itself.

The reviewer proposed moving the clear into the two callers. A literal
move, clearing before the grant call, is NOT behaviour-neutral, so this
does not do that:
- The grant first checks its ledger, which compares the stored hash
  with the one Codex recorded. Codex's hash equals Orca's computed one
  (the premise of readOrcaEntryTrust), so a clear before that check
  deletes exactly the record the ledger proves. Every managed launch
  would then miss the ledger and run an inline session (up to 10 s).
- Checked, not inferred: with the clear moved before the call in the
  managed install, codex-launch-during-real-home-grant.test.ts "settles
  on the managed home..." fails (2 managed sessions instead of 1).
  Log: ~/orca-qa/codex-real-home-leak/fb6/l7-literal-move.log

What this does instead: each caller passes its clear as the grant's
`beforeSession` step, which the grant runs only when a session will
actually run (after a ledger miss, and not on a cooldown or cached
fallback), exactly where the flag ran it. So:
- the flag and its "never set for the real home" rule are gone; the
  real-home grant passes no step, so the grant module has no path left
  that deletes a trust record in ~/.codex;
- the grant module's own lane acquisition around the clear is gone. It
  was always a pass-through: both callers already hold that file's
  lane (the managed install holds the runtime and system lanes, the
  WSL install holds its config.toml lane) across the whole grant.

No behaviour change. The loop-1 probes still pass as fixed: trust-strip
prints every entry trusted after a failed re-grant, and lane-hold
prints managedInstall=settled projectTrust=settled.

Tests (codex-hook-trust-grant.test.ts):
- "removes equivalent Windows fallback keys before the RPC writes
  canonical trust" now passes the managed caller's step;
- new "runs the caller's pre-session step only when a session runs":
  the step runs once for a session and not on the ledger hit after it.

* chore(codex): comments stop describing a lock held across the session, or a rollback

Review loop 2, L6 comment sweep (comments and one test name only):
- codex-trust-config-concurrent-launch.test.ts: the test named "does not
  let a failing launch roll back a concurrent launch" said the per-file
  lane was the only thing left and that the doomed run's rollback must
  not resurrect the file. There is no lane across a session and no
  rollback now. Retargeted to what it covers: "leaves a concurrent
  grant's records in place when a sibling grant fails" (a restore would
  still turn it red).
- codex-trust-grant-ledger.ts: "a grant session blocks launch prep" is
  true only of inline grants; the background one still costs an
  app-server start. The drift clause no longer says "before the pane
  launches", which is false for the real home.
- agent-trust-write-deadline.ts: a stray hard wrap.
The install.ts:105 comment was fixed with L1. A sweep of src/main/codex,
src/main/startup, src/main/agent-hooks, the trust presets and the CLI
handlers for rollback, restore, rebase, capture/restore, and a lane held
across a grant or session found nothing else stale; the remaining "no
restore" comments state the current rule.

* fix(codex): a real-home resume waits for the one running approval, up to its 30 s limit

Review loop 2, M1; coordinator ruling. A resume into ~/.codex has no
managed home to fall back to. 5a737261d8 let it start at once beside an
Orca entry still awaiting Codex's approval. Codex's TUI then shows a
full-screen hook-review picker before the session and waits for keys:
"Trust all and continue" also trusts the user's own unreviewed hooks,
and "Continue without trusting" leaves that session with no Orca status
for its whole life, because Codex does not reload hooks when Orca's
approval lands later. Panes restored at app start after an update hit
it too, since the start-time conversion leaves every entry awaiting
approval.

The resume now waits, but only while Orca's entry in ~/.codex is
written and a grant is approving it (lane 'granting'). Every resume
waits on that same in-flight grant: ensure never starts a second one
while the lane is 'granting', so panes restored together share one
session. The bound is the grant's own session limit (30 s). The grant
settles only after Codex approved the entry, or after it withdrew its
own unapproved adds, so the resumed session starts either trusted or
with no Orca entry: never beside an unapproved one, and no picker. On
a withdrawal that session has no Orca status, as on main after its
10 s wait. A failed approval never fails the resume.

Tests (codex-launch-during-real-home-grant.test.ts):
- "waits for a warm approval, and spawns with the entries approved";
- "spawns at the approval session limit with Orca entries withdrawn"
  (fake timers: pending at 29.999 s, spawns at 30 s with no Orca entry);
- "makes panes restored together wait on one approval session" (three
  resumes, one session, all settle once it lands).
codex-launch-per-agent-hook-opt-out.test.ts: a resume into ~/.codex
awaits the approval; a resume into a managed account home does not.

* fix(codex): repeated background approval timeouts back off, growing to 5 minutes

Review loop 2, M3; coordinator ruling. A timeout of the ~/.codex
approval starts no cooldown, so the next launch retries at once. On a
host where codex app-server never starts within 30 s, every launch then
wrote Orca's entry into ~/.codex/hooks.json, withdrew it again, and
started another 30 s session, for the rest of the process: an unbounded
retry with no exit.

After 3 timeouts in a row the retry now waits 10 s, then 1 minute, then
5 minutes for every later one. The first two timeouts still retry on the
next launch, so a slow cold start is not punished. Any other outcome
ends the streak (a success, or any other failure, which keeps its own
10 s wait). The streak lives only in memory, so every app start begins
at zero and a slow boot can never latch.

Tests (codex-real-home-slow-app-server.test.ts):
- "backs off after three timeouts in a row, growing to 5 minutes, and a
  success resets it": the first two timeouts retry at once, then 10 s,
  1 min, 5 min, 5 min; after a success, a fresh approval gets two
  immediate retries again and a 10 s backoff after the third;
- "keeps trying after timeouts during a slow first start, once the app
  server answers": three timeouts, then the next attempt at 10 s
  installs.

* refactor(codex): one approval at a time, decided under the config.toml lane

The real-home check kept a lane label, a generation stamp, a promise chain of
ensures and a chain of approvals, and decided from the label at call time.
Concurrent resumes from any state other than 'granting' each started their own
approval (N x 30 s), a chained approval ran a plan an earlier failure had
withdrawn, a hooks-off check during an approval released a waiting resume beside
unapproved entries, and an app-start conversion during an approval was dropped.

Now each check is one step under the real config.toml lane: an approval in
flight answers 'approving' (unusable), hooks off answers 'removed', an open
retry window answers 'unavailable', and otherwise the unchanged install runs and
starts at most one approval. The approval settles under the lane: it withdraws
its own unapproved adds on failure, sets the retry, and derives the verdict from
the settings and the outcome, then runs an owed conversion. A resume waits only
while an approval runs and an unapproved Orca entry is on disk. The opt-out
sweep moves verbatim into its own module.

* fix(codex): only a success or app start resets the approval timeout streak

The ruling is that three timeouts in a row back off, and the count resets on
success and at app start. A non-timeout failure or an unexpected error also
reset it, so a host alternating those with timeouts never backed off.

* fix(codex): a Windows profile path the shells cannot carry bare runs through cmd.exe

The Windows hook command was the bare forward-slash script path, or, for a
profile path that is not one PowerShell word, a PowerShell script. That script
cannot parse under cmd.exe, which Codex uses when a session has no single local
turn shell, so such a profile got no status there.

A path of only letters, digits and _ . : / ~ - stays bare. Any other path,
including one with a space, & ^ $ ` ' ! ( ) or a non-ASCII character, is written
as cmd --% /d /c @"<path>", which ran under PowerShell 7, Windows PowerShell 5.1
and cmd.exe for each of those characters with a real Codex 0.158.0. The choice
depends only on the path, so every build on a machine writes the same bytes. A
machine holding the earlier PowerShell spelling converts it once at app start.

* build(cli): list the real-home hook sweep module in the CLI program

* fix(codex): the Windows cmd spelling names the system cmd.exe and turns off delayed expansion

A profile path the shells cannot carry bare was written as
cmd --% /d /c @"<path>". Under Codex's cmd.exe host the outer cmd.exe resolves
a bare `cmd` from the hook's working directory first, so a repo holding
cmd.bat (or .cmd, .com, .exe) at the session cwd would run on every hook event.
And with delayed expansion turned on in the registry, a `!` in the path was
dropped.

The spelling is now <SystemRoot>/System32/cmd.exe --% /d /v:off /c @"<path>",
unquoted (PowerShell reads a quoted first token as an expression) and with
forward slashes. The Windows directory comes from %SystemRoot% when written,
else from the directory above %ComSpec%'s System32, so both give the same bytes;
if neither is a drive-absolute path it can spell unquoted, it is C:/Windows,
which is still absolute. The bytes stay a pure function of the profile path and
that directory, so every build on a machine writes the same command. Safe
profile paths keep the bare path. Older Orca forms, including the bare-cmd
spelling, convert once; the new spelling is never swept as retired.

* refactor(codex): an approval's settle runs no deferred conversion

An app-start conversion that arrived while an approval ran was remembered and
run by that approval's settle. The settle then rewrote an older entry in place,
unapproved, and started a second approval inside the same wait that releases
every resume, so a resume could start beside an entry Codex would put up for
review.

That path could not happen: the only conversion caller is app start, and it is
the process's first check, so no approval can be running when it arrives. The
deferral and the settle's second check are deleted. A conversion that met an
approval would now be skipped until the next start, and the test for this case
pins that the settle writes nothing new and runs one session.

* fix(codex): an approval's settle keeps a failed opt-out's verdict and ends only its own flight

With hooks read off, an approval's settle always concluded 'removed', which the
routing check treats as usable. If an opt-out during that approval could not
read hooks.json, it had concluded 'unavailable' because the entry may still be
there, and the settle overwrote that. The settle now keeps 'unavailable' when
hooks are off; the next hooks-off check or opt-out re-derives it as before.

The settle's fallback when it cannot run now clears the running approval only
if it is still its own, and the routing check's comment states its rule: never
usable while an approval runs.

* fix(codex): spell the system cmd.exe with backslashes

Under Codex's cmd.exe host the outer cmd.exe hands the typed program text to
the child verbatim, and cmd.exe scans its whole command line for switches, so a
forward-slash C:/Windows/System32/cmd.exe is read as switches: the hook never
runs ("The syntax of the command is incorrect.") and /d is lost. Measured live
on Windows; both PowerShell hosts rewrite argv0 and were unaffected. The script
path after @" keeps forward slashes.

* chore(codex): say why the cmd.exe path is absolute, as measured on Windows

* test(codex): Windows managed-install tests expect the frozen command

They still asserted main's PowerShell text and a backslash bare path; they only
run on Windows, so nothing here caught it. Also correct the /v:off comment: a
lone ! is never dropped, only a !NAME! pair expands.

* ci: run the Codex managed-install tests in the Windows job

Its Windows-only cases skip everywhere else, so nothing ran them; three of them
still asserted a command this branch no longer writes.

* ci: a change to the Codex managed-install tests starts the Windows job

Also say what the missing-script case asserts: a non-zero exit, which
PowerShell reports as 1.

* chore(codex): name the hook trust key pattern for what it matches

* test(codex): the managed-install tests remove folders with the retrying helper

Now that they run in the Windows lane, a raw recursive rm there can throw EPERM
after the assertions pass.

* refactor(codex): one Codex hook-trust key pattern for the trust move and #23958's carry

* test(codex): the trust move carries a block in Codex's quoted spelling and leaves no second table
2026-09-30 14:26:23 -07:00
Kelvin Amoaba 2be67ce891 fix(source-control): show git history commit times to the second (#23954) 2026-09-30 14:25:33 -07:00
Brennan Benson afa81dc3ad fix(native-chat): chat failure messages appear in the app's language (#23674)
* fix(native-chat): a read whose history will not open is refused with its reason

History, subscribe, snapshot and options reads reach a chat through one accessor, whose open had no
catch: a journal that would not open reached every client as a runtime error carrying the storage's
own text (a path, "file is not a database"). The accessor, and the options read's own open, now throw
the classified journal refusal: journalCorrupt when SQLite reports damage, journalUnavailable
otherwise. The storage text goes to the log only.

The wire code stays runtime_error and the message becomes the bare code, as for every thrown
refusal; the reason rides in the error's data.

* refactor(native-chat): the idle sweep's stop of a hung start carries no hand-written reason

The sweep passed an English sentence as the stop's reason. It lived only in memory and nothing read
it: the delivery loop words the error row and the rejection from the hostStopped fact. Dropped, with
the display-name lookup that built it.

* fix(native-chat): a refusal the host throws is worded from its data, never its message

A thrown agent-session refusal reaches the client as runtime_error with the bare code as its message
and the typed refusal in error.data. Stop, answers, options and goals, a launch's held option pick,
the option picker's failure toast, and the Retry line of a chat that could not start now word it
from that refusal through the shared notice table. What each caller decides about the outcome is
unchanged: only the words move.

The Retry line of a failed start no longer prints the host's message or a thrown error's text; it
keeps the refusal as a fact and says the cause and step its reason names, or only that the chat
could not be started. The option toast keeps a local option surface's own sentence.

* fix(native-chat): an unreadable history is worded from its refusal, and damage stops the retry

The structured chat's read failure showed the host's text on the status line, and the pane always
said Orca keeps trying. The read transport now takes the refusal from the error's data (a stream
payload or a thrown RPC error), the reducer keeps it beside the failure text, and the pane and the
status line word it through the notice table, once: on the pane when the failure took it, else
beside the transcript that stays.

A damaged journal says "Unable to load this chat." and the read stops reconnecting for that run;
reopening the chat reads again. An open that can clear names its cause without "Try again", since
the pane retries on its own. A failure that names no reason keeps today's generic line. Finality
comes from the refusal's reason, never its message, which is the bare code for both.

* fix(native-chat): a rejected message is worded from its stored fact

A message the host recorded and then rejected keeps the host's typed fact beside its reason, but
the Retry words re-read the reason alone. Now the fact decides: a hand-over failure says Orca
couldn't reach the agent, a kind whose reason may be a legacy marker gets its fact's own sentence
(a full queue now says so instead of only "not sent"), and any other kind shows the sentence the
host wrote for it, which carries the agent's name and any words the provider wrote for a person. A
row with no fact reads as before.

* fix(native-chat): each message that did not go through says why on its own row

The structured chat showed one Retry strip under the transcript for whichever single entry it
picked, so a second failed message had no reason and no Retry of its own. The terminal-backed
chat's existing per-row delivery marker now carries a notice and an optional Retry, and the
structured pane derives one per message from the outbox on each render: every rejected message,
and the one the queue stopped on (read through the drain's own rule, so a Retry never names a
message waiting behind it). Each is worded from that message's stored failure. The single strip is
deleted. Nothing new is stored, and the shared message projection is untouched.

* fix(native-chat): say each chat failure's words where its own control already acts

Three wording rules for the desktop chat:

- A chat that could not start shows Retry beside its reason, so the reason stops at its cause
  where the Retry is the step: a reason whose action is to retry, and a start failure's "send
  your message again". Any other step stays (quit the terminal agent, start a new chat). The
  start-failure sentences take a retryControl context for this; what the host writes is unchanged.
- A history that couldn't open right now still reconnects, so the pane keeps "Orca keeps trying
  to load it" under its cause. Only a damaged history, which no retry reads past, drops it.
- A read failure that names no reason while the transcript is shown is only the pane
  reconnecting: the status line says "Reconnecting to this chat…" in muted text, not an error.
  New key components.native-chat.state.reconnecting, hand-translated for es/fr/ja/ko/zh.

* fix(native-chat): a rejected message offers Retry only once the queue is moving

Each rejected message's row offered its own Retry even while the queue was stopped on another
message. Any Retry clears the stopped queue, so pressing a rejected message's Retry also sent the
message the queue was holding, which the user had not retried; behind a message whose delivery is
unconfirmed, the retried one instead went back into the queue with no notice and waited there.

While the queue is stopped, only the message it stopped on offers Retry, as the single Retry strip
this replaced did. A rejected message keeps its words on its row and gets its Retry back once the
queue moves.

* fix(native-chat): a chat whose history will not open logs once, not on every reconnect

A reader reconnects every 750 ms while a journal open can clear, and each attempt logged the
failure with its full stack. The read door now logs a session's failure once until that session
opens, closes, or fails differently; every attempt is still refused with its reason.

* fix(native-chat): a message's own Retry is its resend step, so its notice stops at the cause

A rejected message offering Retry read "Claude stopped before it finished starting. Send your
message to try again." beside that button. Its row now takes the rule the launch strip already
follows: beside its own Retry the words leave out sending or trying again, worded from the stored
fact with the chat's agent name. The stored fact keeps less than the host wrote from (a refusal,
the provider's words), so a reason it cannot rebuild exactly is kept as written. A rejected
message without a Retry, while the queue is held, keeps the step. What the host writes and the
phone's notice are unchanged.

* fix(native-chat): a message's Retry sends only that message, never the one the queue is held on

Retry released the queue's refusal hold whichever message it was pressed on. While a queued message waited ahead of a held one, every rejected message offered Retry, and pressing it also sent the held message the person had not retried. Retrying an unconfirmed message ahead of a held one did the same. Retry now releases the hold only for its own message.

* fix(native-chat): a not-signed-in failure beside Retry still says to sign in first

Beside a Retry the notice dropped the whole next step, so a chat that could not start because the agent was not signed in read only the cause. Pressing Retry without signing in fails the same way again. The words now keep the sign-in step and leave out only the resend, which the Retry button is.

* test(native-chat): a rejected message's hidden Retry only avoids waiting unseen

* fix(native-chat): every Retry beside a notice leaves out the retry step the same way

A message the queue stopped on worded its refusal with no agent name and with its retry step, beside its own Retry, while a rejected message next to it named the agent and left the step to the button. The launch strip and the history pane each had their own copy of the same rule. One wording context now goes through the one notice table for every surface: a Retry beside the words, or a pane that reconnects on its own, is the step for a reason whose action is to retry, and every other step stays. What the phone and the host write is unchanged.

* test(native-chat): read the sent message id without a type assertion

* fix(native-chat): a rejected message is worded from the journal's own fact, never by comparing sentences

A message the host recorded and then rejected kept only the rejection's kind on the message, so its notice was reworded from that smaller copy only when it rebuilt the host's sentence word for word. A different agent name, an older host's wording, or anything the copy dropped (why a start failed, the provider's own words) left the host's sentence in place, beside a Retry that repeated its resend step. The notice now reads the journal's own rejection for that message, found by id, with the pane's agent name and Retry, and shows the provider's words only when they were written for a person. The message's smaller copy words it only when that journal row is not loaded. Nothing new is stored.

* fix(native-chat): a message rejected before a restart retries under a new id the first time

Whether a Retry needed a new message id was remembered in memory for one message, or read from the journal row when it was loaded. After a restart, or for an older message whose row was not loaded, the first Retry resent under the old id, the host answered with the same settled rejection, and nothing visibly happened. The message now says so itself: one the host recorded and rejected always retries under a new id, including after a restart. A refusal that already gave the message a fresh id, and a message whose delivery is unconfirmed or in flight, keep their id as before.

* fix(native-chat): a rejected message older than the loaded history keeps the provider's words

When the journal row that rejected a message is not loaded, the message's own copy of the
fact has no provider detail or start refusal. For the kinds worded from those, the row now
shows the sentence the host wrote for the person instead of a thinner rebuilt one.

* test(native-chat): the chat pane words a rejected message from its loaded journal row

Nothing covered the pane handing the journal's rows to the per-message notices, so a pane that stopped passing them would quietly fall back to the message's smaller copy of the rejection and show the host's sentence, resend step and all. The new case renders the pane with a rejected message whose journal row is loaded and checks that it reads that row's refusal in the chat's own agent name.

* fix(native-chat): a chat whose history won't load says why in one line

A read the host refused for a named reason put its sentence under the generic
"Could not load conversation" title, so a damaged history read as two lines
saying the same thing. The pane's own sentence now takes the title's place; a
history that can come back keeps its line saying Orca keeps trying. A failure
that names nothing keeps the generic title.

* fix(native-chat): a message a failed start rejected says only that it was not sent

When an agent stopped before it finished starting, the chat showed the start's
red row ("Claude stopped before it finished starting. Send your message to try
again.") and then repeated that cause under every message the start rejected.
Each of those messages now reads "Your message was not sent." beside its Retry.

The match is made on typed facts, not on the words: the host writes the start's
row and the rejection of its queued messages from the same failure fact, and the
row is keyed by the start. The pane finds the loaded start-failure rows by that
key and shortens a message's notice only when its loaded journal submission was
rejected with the same fact. Any other rejection, or one whose submission or row
is not loaded, keeps its full notice. The row key moves to a shared module so the
host that writes it and the pane that reads it use one definition.

* fix(native-chat): a chat whose history keeps failing to open retries less often

A read the host kept refusing (its history store could not be opened right now)
reopened every 750 ms for as long as the chat stayed open, about 40 opens every
30 seconds. Each reconnect now waits twice as long as the last, from 750 ms up
to 30 seconds, and never gives up; the first read that delivers anything starts
the wait over at 750 ms. A damaged history still stops reconnecting at once.

Reset happens on a delivered read, not on connect: a local subscribe resolves
before the host's open refuses, so resetting there would keep the 750 ms loop.

* test(native-chat): the pane harness types its journal rows without a cast

* test(native-chat): the admission test passes no start-failure rows to the notices

* test(native-chat): import the journal types once

* fix(native-chat): a remote chat reads again as soon as its host is back

The read retry doubles its wait up to 30 s during an outage, and nothing
reset it when the remote runtime reconnected, so the transcript could
lag the reconnect by up to 30 s. The read now watches the runtime
status store's contact-regained edges (hostContactEpoch for a
same-runtime return, connectionGeneration for a new runtime session)
and, when one lands, runs a waiting retry immediately with the wait
reset to its base.

* refactor(native-chat): build each failure sentence from whole pieces

Every sentence agentSessionFailureWords writes is now assembled from a
table of whole English pieces, so a reader can supply its own words for
each piece. The host still fills them in English, byte for byte as
before.

* fix(native-chat): translate the failure sentences desktop notices show

A refused start and a rejected message now carry their failure fact to
the notice instead of its English sentence, and desktop words that fact
through translate keys whose English defaults are the host's own
pieces. The host keeps writing English into rows and reasons, and a
host sentence with no fact beside it still shows as written.

* fix(native-chat): the history pane says only that Orca keeps trying

When the pane's title already says Orca couldn't open this chat's
history right now, the line under it no longer repeats that the
transcript could not be read; it says only that Orca keeps trying to
load it. The pane with no named reason keeps its two-part line.

* test(native-chat): type the failure pieces a refusal notice shares

* test(native-chat): the Chinese failure words use no Japanese-only characters

* fix(native-chat): every history pane that says it didn't load says only that Orca keeps trying

A pane whose title is a code's own words ("This chat's history couldn't
be loaded.") now gets the short retrying line too. Only the pane with no
named reason keeps the two-part line.

* test(native-chat): a provider's words with nesting and markup stay as written in a translated notice

* fix(native-chat): French and Spanish say a withdrawn message was withdrawn before the agent began working on it

* fix(native-chat): Japanese and Chinese notices run their sentences on without a space

A notice joined its sentences with a space in every language, so Japanese and
Chinese read "Claude 无法启动。 请重新发送消息。" with a stray gap after the full
stop. The failure-sentence builder now takes the joiner alongside its words, and
desktop joins in the UI language: no space in Japanese and Chinese (including a
plugin pack that declares either), one space elsewhere. The host and the phone
keep English, joined with a space as before.

* fix(native-chat): a failed /clear or /compact says why in the app's language

The line under the composer printed the host's English sentence although the
result carries the typed failure beside it. It now words that failure the way
the host does (the chat's agent and /clear for a failed /clear, nothing for
/compact), in the app's language; an older host that sends no failure keeps
its sentence.

* fix(native-chat): Spanish says a rate limit, and Korean says a withdrawn message was never processed

The Spanish retry notice said the agent hit a usage limit, a different thing
from the rate limit the English names. The Korean withdrawn-message notice
said the agent had not started, which reads as the agent not launching; it now
says the agent had not begun processing the message, as the other languages do.

* refactor(native-chat): one rule says which words already say the history didn't load

* fix(native-chat): an image size limit says its unit the way the reader's language does

* test(native-chat): the option picker's i18n stand-in knows the reader's locale, which a refusal notice now reads

* fix(native-chat): a sentence a language pack left in English keeps its space

Sentences were joined by the UI language: no space in Japanese and Chinese,
one elsewhere. A plugin pack for a Chinese or Japanese variant that predates
the failure words falls back to English for them, so a notice read
"您的訊息未傳送。Claude couldn't start.Send your message to try again."

Each gap now follows the sentence before it: none after a full-width 。!?,
one space after anything else. The built-in Japanese and Chinese catalogs end
every sentence in 。, so they read as before, and English is unchanged. Since
the rule no longer needs the language, one joiner serves every surface and
the failure-sentence builder no longer takes one alongside its words.

* fix(native-chat): a failed /clear this build only partly understands shows the host's own sentence

The line under the composer words a failed /clear or /compact from the fact
the host sends beside its sentence. The reader drops any part this build
cannot place, such as a refusal code a newer host added, and the rest of the
fact can then give different advice: "Run /clear again." where the host said
"Start a new chat to continue."

When any part the host sent did not survive the read, the line now shows the
host's sentence as written, the same as for a host that sends no fact. A fact
this build reads whole is still worded in the app's language.

* fix(native-chat): a failure fact this build reads only in part shows the host's sentence everywhere

The previous check compared only a fact's top-level parts, so a known refusal
code carrying a reason a newer host added still counted as read: the reader
dropped the reason and the notice re-worded what was left, which can advise
differently from the host ("Run /clear again." against "Start a new chat to
continue.").

One shared reader now answers whether this build read the whole fact: it reads
the fact and keeps it only when the read equals what arrived, at every depth.
Every place that chooses between wording a fact and showing the host's text
uses it: the line under the composer after /clear or /compact, a rejected
message's notice from the journal's fact, and the smaller copy a rejected
message keeps for when its journal row is not loaded. Matching a rejected
message to the start row that already says why still uses what this build can
read, since that is identity, not wording. Facts this build reads whole are
worded as before.

* fix(native-chat): a failed /compact names /compact as its next step on desktop too

The host names the command a failed start was waiting on, for /clear and /compact alike.
The line under the composer re-worded only /clear with it, so a /compact whose start failed
read "The agent couldn't restart. Send your message to try again." in the reader's
language. It now words every command the host answers with the agent and the command the
host used, so desktop English matches the host and French or Japanese keep /compact.

* fix(native-chat): a /compact whose start failed no longer says the operation was not confirmed

A /compact on a chat whose agent is not running starts it first, and that start takes a new
lease, so the chat's fence moves before the command's reply arrives. The write settles as one
for a fence this pane no longer shows, and the composer line read that as "Conversation
operation was not confirmed." Such a write now says nothing there, as every other write
already does: the chat's own start-failure row says why, and the command's message is
rejected in the journal. The sentence it printed is gone from the catalogs.

* fix(native-chat): keep the message outbox within its line limit after main's growth

* fix(native-chat): a command's own reply is kept when its start moved the fence

A /clear or /compact on a chat whose agent is at rest starts the agent first, and that start
moves the chat's fence before the command's reply arrives. Every reply from an earlier fence was
discarded, so a /clear whose new chat failed to start said nothing at all, and a /compact that
started left "/compact" in the composer.

A conversation command's reply is now kept while the pane still shows the chat it was sent for;
a closed pane or another chat still drops it, and every other write keeps the fence rule. The
line under the composer says nothing only when the failure is the chat's own start and that
start's loaded row already says why, the rule a message that start rejected already follows.

* fix(native-chat): a returned queued message shows the host's sentence for a fact read in part

The caption under a returned queued message re-worded its failure from whatever this build could
read of the fact. A newer host's fact with a refusal code or reason this build drops read as a
shorter sentence with different advice. It now re-words only a fact read whole, and otherwise
shows the host's own sentence, as every other surface that re-words a fact does.

* test(native-chat): a command reply after any fence move, for /clear and /compact

The fence-move tests now state the rule as the code has it: a conversation command's reply is kept
whenever the pane still shows its chat, whatever moved the fence. They cover a /clear that
completed, and a failed start for each command in French with the fact that command really meets
(a /clear's new chat fails to start; a /compact's chat fails to restart).

* fix(native-chat): only the reply a pane still waits on outlives a fence move

A conversation command's reply was kept across a fence move whenever the pane still showed the
same chat. A reply the pane had stopped waiting on, because it left the chat and came back or a
newer command replaced it, was applied as if it answered the current one.

The pane now remembers the one command request it waits on; only that request's reply is kept
after the fence moves, and closing the pane or showing another chat forgets it. Tests now drive the
fence move during the request itself rather than through /clear starting an agent, and cover a
/clear that stops a running agent.

* fix(native-chat): a newer-Orca history error keeps a whole retry line, and the phone shows the host's words for a fact it reads in part

When a chat's history was saved by a newer Orca, the pane's title reads "Chats were saved by a newer
Orca. Update Orca to keep using them." and the line under it said only "Orca keeps trying to load
it.", with nothing for "it" to mean (in French and Spanish the pronoun also disagreed with "chats").
That title names every chat rather than this one, so the pane keeps the full line: "The transcript
could not be read. Orca keeps trying to load it."

On the phone, a returned queued message whose failure fact this build reads only in part was
re-worded from what it could read, dropping advice the host gave; it now shows the host's own
sentence, as the desktop card already does.

The comment on the reply the pane waits on now says what forgets it: a newer command, or disabling
the pane.

* fix(native-chat): a command reply this build can't place shows the host's own words

A newer host can answer a conversation command with a command name this build doesn't know. This
build re-worded that reply from its failure fact as if it were a command it knew, naming the
command in its own words, or said nothing when a loaded start row matched the fact. It now shows
the host's sentence as written, as it already does for a fact it can read only in part.
2026-09-30 14:15:07 -07:00
Brennan Benson 7cae036ebf fix(native-chat): a failed Codex turn shows its error, not a raw thread-status row (#23704)
* fix(native-chat): a failed Codex turn shows its error, not a raw thread-status row

Codex reports `thread/status/changed` {systemError} just before the `error`
frame of a failed turn. The translator read it for session state, then let it
fall through to the generic-frame fallback, whose payload check reads
`systemError` as a failure and printed "codex · notification:thread/status/changed"
in red above the real error row.

The translator now owns the notification: it reads the stopped-running verdict
exactly as before, then journals nothing for any arm (idle, active, notLoaded,
systemError). The error row that follows still carries Codex's sentence and
still fails the turn.

* refactor(native-chat): the Codex thread status is owned through the typed-translator registry

The translator already reads every thread status for session state. Listing
the kind beside Claude's background-task frames makes that ownership visible
where the fallback consults it, instead of a second method check in the
translator.

* chore(native-chat): the typed-translator registry key is checked against the provider table

A mistyped provider key compiled and silently brought the row back. Also
retire the renamed constant from a Claude test comment and say which path
the classifier-only fallback test covers.

* fix(native-chat): the Codex translator vouches only for the thread status it read

The covered flag means this exact frame was handled; passing it for every
notification would silently hide any Codex kind later added to the registry.
Also say why a state-only kind may be listed, and retitle the cross-provider
coverage test now that Codex has entries.
2026-09-30 14:13:51 -07:00
Brennan Benson 007b7c0d32 fix(claude): end a message Claude started but never confirmed, and keep Claude running while it holds one (#23898)
* fix(claude): settle a queued send the CLI withdrew from its own cancelled frame

Claude reports each uuid-stamped command's lifecycle (queued, started,
completed, cancelled). A send it withdraws from its queue gets `cancelled`
before the interrupt or cancel_async_message answer, so a lost or failed
answer no longer leaves that send pending: it settles as withdrawn, with the
same reason and words as the receipt path.

A command the CLI already started also ends `cancelled` when its turn is
interrupted or fails, so `cancelled` after `started` is not a withdrawal;
an echoed send has left the waiter lists and is never reached.

Tests replay real 2.1.280 captures, scrubbed.

* fix(claude): release a doubted send when the CLI reports its session idle

A Claude send whose write ended in doubt is recorded `unknown`, and a live
`unknown` reads as work still owed, so the chat showed Working until the
child exited. Claude sends `session_state_changed idle` only once its whole
queue has drained, so it can no longer be holding that send. The runtime now
routes that report to the host's existing release, the same one Codex's
thread-stopped report uses; it retires `unknown` only, never `pending`.

* fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1

* fix(claude): settle every terminal lifecycle state of a send the CLI never echoed

A send the CLI started, then cancelled before any echo, stayed pending: it may
already be in the conversation, so it is released as doubt (unknown, recovered),
never withdrawn and never re-sent. A late echo still accepts it.

The 2.1.280 schema has two more terminal states. `discarded` (the CLI ended its
session with the send still queued) settles as not delivered; `refused`
(declined before it queued) settles as not accepted by the provider. After
`started`, either one is doubt, as `cancelled` is.

The late-settlement path gains an `unknown` outcome, which the host records as
released doubt.

* fix(claude): release a send the CLI took but left unanswered when it goes idle

`session_state_changed idle` comes only once the CLI's queue has drained, so a
send it took that is still unanswered there got no echo and never will: a turn
that throws can leave `started` with no terminal state. Idle releases it as
doubt.

What proves the CLI took a send is its lifecycle frame. On a CLI that reports no
lifecycle, it is the send's place on stdin: one whose write finished before an
interrupt went out was read before the interrupt was, so the first idle after
that interrupt releases it too. A send armed ahead of the interrupt but written
after it is left alone, since the CLI may still run it.

* fix(native-chat): keep the idle sweep off a Claude child that holds a send

A Claude retrying a rate-limited request has taken the send but echoes nothing,
so no turn row exists yet and the sweep rested the child after the idle window,
turning the send into doubt. The adapter now reports whether the CLI holds a
send (lifecycle `queued` or `started`, not yet echoed or ended), derived from
the live waiters, and owed work counts it.

Nothing is stored: every held send leaves the live set on its echo, its
terminal lifecycle state, the CLI's idle, or the child's exit, so the hold ends
with the send.

* fix(claude): count only a started send at idle and as a held send

2.1.280's end-of-turn cleanup can report idle before it re-reads its queue, so
a send read in that window goes queued, idle, started. Releasing every taken
send at idle doubted that live send and dropped Working. Only a `started` send
is released at idle or keeps the child from the idle sweep; a `queued` one ends
by starting and echoing, by a terminal lifecycle frame, or with the child.

The stdin-order path for CLIs without lifecycle frames is removed: a doubted
send retired there disables content matching on CLIs that mint their own echo
ids, and no Orca failure called for it. Those CLIs keep the earlier behaviour.

Comments that said only a failed write or child exit ends a waiter, or that
idle comes only once the queue has drained, now say what ends one.

* docs(claude): say only what the CLI's lifecycle frames and idle actually prove

* fix(claude): hold the idle sweep while Claude has a send queued, not only started

The sweep rested a child whose CLI had queued a follow-up behind a turn, dropping
the send it had already taken. The hold now spans the CLI reporting it took the
send until its echo, a terminal lifecycle state, or the child's exit. The idle
release still covers only started sends: 2.1.280 can report idle before it
re-reads its queue.

* refactor(native-chat): give provider-proven late dispatch settlement its own module

* test(claude): pin a steer a Stop interrupts after it started as doubt, not withdrawn
2026-09-30 14:11:41 -07:00
Jinwoo Hong ed462b2caa fix(relay): re-place hosts off a draining cell without locking its row (#24216)
* test(relay): reproduce drain-release contention against director placement

Adds a Postgres harness that drives releases from an isolated cell over a
171 ms per-statement pool while five directors re-place reconnecting hosts
through the sticky lane. At 18 releases/s placements fall from 20/s to about
5/s and every active director backend is blocked on relay_cells.

Moves the per-statement delay pool into a shared test fixture so the
rehome target-row test and this harness use one implementation.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): re-place hosts off a draining cell without locking its row

Sticky re-placement of a host whose cell is isolated for a roll locked
every relay_cells row. During an Asia drain the source row is held by the
cell's own releases for a round trip each, so the placement waited on it,
the single sticky slot backed up, and /v1/assign returned 503 fleet-wide.

The isolation decision now comes from an unlocked read, and the placement
locks only the same-region general rows it can move to. It no longer writes
the source row: the host's source leases stay, and each one's own release
or expiry takes its units back off the source. The assignment keeps its
activity counters and adds one control instead of resetting them. With no
same-region headroom the path falls back to the all-rows lock, as before.

Dormant hosts hold no units, so their placement also locks only the
general rows and skips the zero write to their old cell.

The drain harness now asserts the after picture: 20 placements/s at Asia
latency with no director lock waits, against 9.2/s and 4.8/s before.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): take a moved host's units off the cell that holds them

After a narrowed re-placement a host keeps leases on its old cell while
its assignment row names the new one. Two paths charged the row's whole
counted total to the row's cell: aggregate expiry, and the lease deletion
in dead-cell and stranded re-placement. Both over-charged the new cell and
left the old cell's units stranded.

Aggregate expiry now skips hosts that still hold any lease; the lease
sweep takes each lease's units off its own cell. Placement frees each
deleted lease's units on that lease's cell, charges the old cell only for
units no lease backs, and sets the counters from the leases it keeps plus
the new control. The narrowed path runs only when the counters already
match the leases, so it never needs to write the old cell's row.

The all-rows re-placement off an isolated cell follows the same rule, so
it no longer decrements the source at placement either.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): try other regions before the all-rows lock when re-placing off a roll

With every same-region neighbour at its connection cap, the narrowed path
found no target and fell back to the all-rows lock behind the busy source
row, which is the drain brownout again. It now tries a second tier, general
cells in every other region, in its own transaction over one ordered
lockCellRows, still never the source row. Only when no general cell in any
region has room does it fall back to the all-rows path, which keeps the pin.

This changes the policy from #21911, which refused to re-place an isolated
host across a region. The unit tests that encoded that rule now assert the
tier order instead.

The drain harness gains a US cell and an arm with every Asia neighbour
capped: 200 of 200 dials placed cross-region at 20/s with no lock waits,
against 0 placed and 144 sticky rejections on the previous head. Its pass
bars are now the rejection share and the lock-waiting share, not the
placement rate a slow runner's pacing can move.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): take the narrowed path for hosts whose counters sit below their leases

Main's old placement reset a moved host's counters while keeping its source
leases, and those leases' releases floored the counters at zero. Such hosts
hold fewer counted units than lease units, and requiring equality sent them
down the all-rows path behind the busy source row.

The narrowed path now requires only that the host holds no units no lease
backs, the one case that needs a write to the old row. Its placement
already rebuilds the counters from the kept leases plus the new control,
so a drifted host is healed by its next re-placement.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* test(relay): judge the local drain arm on completion and lock waits, not rate

The local-latency arm asserted at least 18 placements/s at a 20/s dial rate,
which a slow runner's pacing alone can miss. It now asserts what the Asia
arm does: no dial failures, sticky rejections under 10% of dials, every
other dial placed, and director lock-waiting under half a backend.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-30 17:00:03 -04:00
Brennan Benson 95e8725b40 fix(claude): stop Orca making a WSL user's ~/.claude.json world-readable (#23973)
* fix(claude): keep a WSL guest's ~/.claude.json mode when Orca writes folder trust

* test(claude): assert a skipped trust write leaves no replacement file behind

The replacement file is now created before Claude's lock is taken, so the
locked path must remove it.
2026-09-30 12:48:51 -07:00
Neil cfe4c633eb fix(mobile): publish the Android APK's size and checksum with the release (#24037)
An APK that fails to install with a missing certificate or a package-parse error
is usually a download that died near the end: the signature block sits in the
last ~100 KB of a 133 MB file, so a truncated APK looks complete and carries no
signature at all. The release published neither a size nor a digest, so there
was no way to tell that apart from a bad build without deriving both from the
asset by hand.

The release now uploads app-release.apk.sha256 next to the APK in
`sha256sum -c` format (binary marker, so Git Bash cannot translate line endings
while hashing) and puts the exact byte size and digest in the release body,
naming `shasum -a 256 -c` for readers on macOS.

The upload path rewrites the body too: --clobber replaces the APK, so a digest
left over from the previous build would describe a file nobody can download,
and a reader comparing against it would reject a good APK. Both paths reserve
the section's own length out of the release-body cap before truncating, so the
section always survives and the body always fits; MAX_RELEASE_BODY_LENGTH is
exported from the desktop release script rather than restated.

Refs #24011, #12248, #11444.
2026-09-30 12:14:13 -07:00
FenjuFuandJinjing 2caa79e097 fix(source-control): drop reasoning model think blocks from generated messages (#24005)
* fix(source-control): drop reasoning model think blocks from generated messages

Custom commit-message commands that run a reasoning model print the
reasoning before the answer, either as a <think>...</think> block or,
when the chat template prefills <think>, as text ending in a lone
</think>. The cleaner kept all of it, so the first reasoning line became
the commit subject.

Strip everything through the first </think> when the output starts with
<think> or has no <think> before the close tag. Output that quotes both
tags is left unchanged.

Fixes #24004

Signed-off-by: FenjuFu <fufenjupku@gmail.com>

* fix(source-control): scope lone </think> stripping to custom commands and add Kimi-VL tags

A lone closing tag is only stripped for custom commands, so a built-in
agent's message that mentions </think> is kept. PR fields parse the raw JSON
first and strip reasoning only when that fails, so a body quoting the tag
still parses. Adds Kimi-VL-Thinking's ◁think▷ tags.

---------

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-09-30 12:12:21 -07:00
373d951bf0 fix(editor): highlight shell startup dotfiles (#24066)
* fix(editor): highlight shell startup dotfiles

Opening ~/.zshrc, ~/.bashrc or ~/.profile rendered as plaintext because
extname() treats a leading-dot name as having no extension, and Monaco's
shell association only lists .sh/.bash.

Map the common bash/zsh/POSIX startup filenames to the shell language in
FILENAME_TO_LANGUAGE so both the editor and diffs highlight them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(editor): cover every mapped shell dotfile

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(editor): match exact filenames case-insensitively

Shell dotfiles (.ZSHRC, .BASHRC) on case-insensitive filesystems
still fell to plaintext. Exact match wins; lowercase fallback covers
the rest without touching extension/Monaco order.

---------

Co-authored-by: djlee <djlee@woowahan.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-09-30 12:01:44 -07:00
Brennan Benson ba39c6d5fd fix(native-chat): a failed startup chat-lease save no longer puts the app into "Session restore failed" (#23964)
* fix(native-chat): report a failed startup chat reconcile instead of failing app startup

At startup the chat host re-checks every saved chat's lease and writes the
result to agent-sessions.json. If that write failed (the file lock gave up,
the file could not be written, or the file was written by a newer Orca and
is read-only here), reconcileRestartLeases rejected, the startup IPC call
rejected, and the renderer fell into its degraded "Session restore failed.
Changes won't be saved until restart" mode.

The reconcile is bookkeeping: a lease left unreconciled grants no writer,
and every attach, send and read of a chat reconciles its own lease again.
So the startup reconcile now reports its failure through a new optional
host dependency, onStartupReconcileFailure, and resolves. The runtime
routes it to its onError sink under the scope
structured-agent-session-startup-reconcile, or logs it when no sink is
installed (the desktop installs none).

* fix(native-chat): read restored chats without waiting on lease bookkeeping

With native chat on and a chat tab open at quit, the renderer's startup
also awaits the chat tab restore (session.tabs.listAll). That restore
re-ran the lease reconcile before reading each chat and rethrew its store
failure, then recorded each restored tab as visible through a store
transaction that throws on a held lock or a read-only store. Either one
failed the restore, so startup still fell into "Session restore failed".

Reading a chat grants no writer, so the reconcile startup and the restore
run is now a reader's: createReaderReconcile never throws, answers whether
every lease is settled (recovery is resolved only then; the journal opens
either way), and reports each distinct failure once until a reconcile
settles. Attach and agent start keep the strict reconcile. The restore's
tab republish logs a failed visibility write and still publishes the tab,
since a client drops every unpublished chat tab; user-driven publishes
still refuse.

The host dependency is renamed onLeaseReconcileFailure (scope
structured-agent-session-lease-reconcile), since it now also reports for
reads.

* fix(native-chat): keep every record-store write off the startup chat read path

Round-2 review found two more writes on the startup chat restore that
could still fail it and put the app into "Session restore failed":
republishing a /clear replacement recorded its tab visibility strictly,
and resolving a chat's recovery rethrew its store error. The restore
also paid one lock wait per tab and per batch of chats while the lock
stayed held.

The restore now derives tabs from state it already holds:
- publishStructuredAgentSessionTab splits into the strict write and
  projectStructuredAgentSessionTab, which only updates the runtime's
  snapshot. The restore and /clear replacements only project: a saved
  tab index already lists every restored chat, and a /clear moves the
  tab in the same write that commits it. visibilityWriteMayFail is gone.
- Chats a legacy profile restores that the index does not list are
  recorded in one best-effort transaction (store.showSessionTabs), so a
  failure leaves the index absent to seed again rather than partial.
- The read restore's recovery resolution is caught and reported through
  onLeaseReconcileFailure, deduplicated with the reconcile's reports.
- Once lease bookkeeping fails in a restore pass, the rest of that pass
  skips it, so a held lock costs one wait for the startup reconcile and
  one for the restore, however many chats are open.

User actions (create, reveal, attach, send, the /clear commit) keep
their strict writes.

* fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure

The restore now runs one reader lease check for the pass and lets each chat
re-check and resolve recovery only while the pass is still settled. The
first refusal or failed write clears it for the rest of the pass, and every
chat is still opened for reading. With another process holding the lock,
startup waits on it once in prepare and once in the restore, however many
chats are open; a legacy profile waits once more for its tab-index seed.

* docs(native-chat): correct restore comments and a test name to match the final design

* fix(native-chat): keep a throwing failure sink from failing the startup chat read

The lease bookkeeping failure reporter called the host's failure sink
directly, so a sink that threw turned a reported, recoverable store failure
back into a rejected startup reconcile or read restore. The reporter now
catches a sink throw and logs both the original failure and the sink error
with console.warn.
2026-09-30 11:45:05 -07:00
Brennan Benson cb363444f3 feat(native-chat): register Codex default-mode helpers as subagents (#22619)
* feat(native-chat): register Codex default-mode helpers as subagents

Codex's default multi-agent mode announces a helper only as the
collabAgentToolCall that spawned it; it sends no subAgentActivity. The
roster and the background-task tracker registered children only from
subAgentActivity, so such a helper had no record, no strip row and no
roster row, and its commands read as the session's own.

One announcement reader now yields a child from either wire shape, and
both the journal roster and the tracker register through it into the
same executions, so a session sending both keeps one child per thread.
A finished closeAgent ends the helper's running turn as stopped through
the executions, beside the child's own turn and thread frames.

Each collab call renders as a tool row (spawn_agent, wait_agent,
close_agent, ...) naming the helper the way the roster does, with what
the helper said back as its output, instead of the raw provider row.

* fix(native-chat): name a spawn row by its prompt until the roster holds its helper

Also pin that the roster row appears when the helper's first turn arrives
before the spawn call finishes.

* test(native-chat): a spawn call keeps its own row beside the roster row it starts

* fix(native-chat): pair a structured tool row's output with its own call

A run paired results to calls by position alone, so once one call finished
with no output (a Codex spawn_agent row) every later output drew under the
call before its own. A structured row carries its call and output together,
so the projected result now names its call id and pairing honors it,
falling back to position for results that name none.

* fix(native-chat): the Codex roster row follows the executions, so every child ending settles it

The subagent-group row was rewritten only on a child's turn/started and
turn/completed. A child turn ended any other way — a fatal error, its
thread closing, its caller closing it — settled the strip and the host
record through the executions but left the transcript row reading working
until the session ended.

The executions now say when a child's execution changes, and the roster
revises its row from that, whichever frame changed it. handleTurn still
re-derives to hand back its write admission; the revision is idempotent.

* fix(native-chat): a restored Codex call row names its helper, not its thread id

History replay never runs the live item router, so the roster never learned
the helpers a restored thread had spawned, and a restored wait_agent,
close_agent or send_input row labelled its helper with the raw thread id.
Replay now registers each announced helper for its name and membership only:
register starts no execution, so no strip entry, record or roster row claims
the helper runs until a live turn of its own says so.

The replayed-item handling moves into the restore module beside the replay
that calls it. Also name the roster's render contract in handleItem, and say
why the collab tool-name map is not a spelling fix.

* fix(native-chat): register a Codex helper whose spawn call failed but created its thread

Codex reports a spawn as failed when the helper it created errored at birth,
yet the call still names the thread it created, and that thread can run. The
spawn was registered only when the call completed, so such a helper existed
nowhere and its shell read as the session's own bare command: the original
default-mode bug. A spawn now registers whenever it names a receiver; one in
progress, or one that created nothing, names none. A failed closeAgent still
ends nothing, because the helper was not closed.

* refactor(native-chat): each Codex thread's latest token total gets its own home

Every thread's running total, member or not, with the rule that the newest
frame replaces the last and the recency-ordered cap, moves out of the roster
into codex-thread-token-totals.ts. The roster still selects its children's
totals at write time. With the producer linkage the roster now builds, it
was past the size limit.

* refactor(native-chat): the messages a session list quotes get their own module

The readers of a session's newest own prompt and own assistant prose move from
the status projection into structured-agent-session-latest-messages.ts, and
are re-exported so their consumers keep one import site. With the status
clock the projection now carries, a tool result naming its call put it past
the size limit.

* test(native-chat): a Codex helper's command is a live record until its process reports its exit

A helper's in-turn shell is a live command record owned by the helper and is also its open
Bash operation; its exit removes the record. A caller's closeAgent kills the helper's
processes, and each still reports its exit on the helper's thread, so the close leaves the
command to that exit rather than ending it itself.

* fix(native-chat): every reader of a tool run pairs a result with the call it names

The folded desktop run now pairs a result with the call it names, but mobile's tool run, the desktop edit cards and the task lists still paired by position through `pairToolBlocks`. In a Codex default-mode session the new `spawn_agent` row finishes with no output, so on mobile the helper's reply drew under `spawn_agent` and `wait_agent` showed none, and a desktop edit card could take the next command's output as its own.

`pairToolBlocks` now follows the same rule as `pairNativeChatToolResults`: a result that names its call answers that call, and one that names none answers the oldest unanswered call as before.

* fix(native-chat): a Codex helper's turn ends on its own frames, not on its caller's closeAgent

A finished `closeAgent` call ended the helper's running turn as `stopped`. But the call's status is not the close's outcome: Codex sets it from the helper's own agent status, so a close that errors on a running helper still reports `completed`. Orca then marked a helper that was still running as cancelled in the strip, the host record and the roster row, and because the first ending a turn gets stands, its real ending could never correct it.

A close that works does not need the edge either. Codex's shutdown of the helper aborts its running turn, and the app-server reports that as the helper's own `turn/completed` with status `interrupted`, which Orca already maps to `stopped`. So a helper's turn now ends only on its own frames (its `turn/completed`, a fatal `error`, `thread/closed`) or the session ending, and the close is only its call row.

This also drops the child-work evidence re-keying that existed only for the close: every remaining frame names the thread that sent it.

* fix(native-chat): a tool result that names a call is never given to a different call

A result that named a call id with no unanswered match fell back to the oldest unanswered call. On mobile, a run shows at most six calls, so a command past that window whose output named its own call gave that output to an earlier call that finished with none, such as `spawn_agent`.

A result that names its call now answers only that call and otherwise stays unpaired. A result that names no call still answers the oldest unanswered one. Both pairing readers share the one rule through `answeredToolCallIndex`.

* test(native-chat): name the Codex default-mode test file after the subagents it covers

* fix(native-chat): a Codex helper is known from any call that names it, not only its spawn

A helper whose spawn Orca never saw (history replay after compaction dropped the spawn, or a resume or message to a helper from earlier) stayed unregistered, so its shell read as the session's own command: the bug this PR fixes, in another shape.

Every collab call now announces each helper it names, skipping a receiver the finished call reports notFound. The spawn's prompt still labels a helper when it was seen; otherwise the helper has no label and reads as any unnamed subagent does. A send_input, like the spawn, names the parent turn the helper's next run belongs to.

The session's own thread is excluded once, in the reader, instead of at each of its three callers.

* fix(native-chat): every finished Codex collab call row carries what the call reports

A client that predates result call ids (an older mobile app, or an older desktop reading a newer host) projects the journal itself and pairs each tool result with the oldest unanswered call. This PR publishes collab calls as tool rows, and a finished spawn_agent, send_input, close_agent or resume_agent on a running helper had no output, so such a client drew each later output in the run under the call before its own (the parent's shell output under spawn_agent, the helper's reply under the shell).

Every finished call now has an output taken from the item: a wait's reply from each helper that finished (an errored helper's error), and for every other call the helper's reported status in Codex's own words (Pending init, Running, Completed, ...). A close or resume no longer shows the helper's last reply as if the call returned it. A wait whose end names no helper (it timed out, or v2) reads Finished waiting; any other call with no state reads its own status.

A call also keeps naming the helpers its started item named: Codex ends a timed-out wait with no receivers, so its row lost the helper's name when it finished.

* refactor(native-chat): the desktop run's tool pairing is a view of pairToolBlocks

Desktop runs and every other reader (mobile, edit cards, task lists, the ask row) each had their own pairing loop sharing one index rule. The desktop's pairNativeChatToolResults now reads the pairs pairToolBlocks makes, so a run is paired by one loop and a new reader cannot add a third. No behaviour change.

* test(native-chat): type the positional-client pairs without assertions

* fix(native-chat): a finished Codex collab call row says what the call did, in Codex's own words

A close_agent row read `Running` and a spawn_agent row `Pending init`: each showed the helper's status snapshot from before the call took effect, so a close that stopped its helper read as though it had not worked.

Each finished call's output now follows Codex's own client: spawn reads `Spawned` (or `Agent spawn failed` when no helper was created), send_input `Sent input`, close `Closed`, and resume the helper's status summary. A wait keeps each finished helper's reply, with other statuses in Codex's summary wording (`Error - <message>`). The row label already names the helper, so the output does not repeat it.

* docs(native-chat): the collab call reader says which calls show the helper's snapshot as output

Since spawn, send_input and close rows say what the call did, the reader's header was wrong to claim every call row shows the reported snapshot as its output: only a wait or resume does.

* fix(native-chat): a Codex spawn call no longer claims it ran an agent

Codex's spawn_agent call ends as soon as the helper starts, so counting it as running an agent drew "Ran 1 agent" directly above "Kicked off 1 subagent · working" while the helper was still working, and "Ran 1 agent · 1 failed" when Stop cancelled a spawn before any helper existed. Claude's Task call lasts as long as its subagent, so it keeps the agent category; the Codex spawn row is now a plain tool call and the roster row alone stands for the helper.

* test(native-chat): a Codex helper's row settles on the failed completion that follows a fatal error

Codex follows every turn-ending `error` with the turn's failed `turn/completed`,
on a helper's thread as on the primary, and only that completion ends the turn.
The roster test now sends both and checks the row, strip and record stay
working through the error and settle on the completion.

* fix(native-chat): a Codex helper's section opens while the parent spawns, waits on or messages it

A running chat holds a subagent's section open while the parent's newest row delegates to that subagent. For Codex that rule only knew the raw collab status row; this PR writes each collab call as a tool row (spawn_agent, wait_agent, send_input, close_agent, ...) naming its helpers in `input.agents`, so a default-mode helper's section stayed shut for its whole run while Claude's opened.

The delegation reader now reads a Codex collab tool row as a delegation to the first helper it names, the same rule the raw row keeps for journals written before this change; a call naming no helper stays ordinary output. The row names and the `agents` key live in one shared module the host writes from and the reader reads, instead of a second list.

The new test drives the real adapter's rows through the transcript projection to the delegation; the collab frame harness moves to a fixture shared with the positional-clients test.

* fix(native-chat): a Codex collab call with a long prompt still names its helper

A collab call row bounded its whole input as one value, so a spawn or send_input whose prompt passed the 16 KB journal limit was stored as a clipped wrapper: the row lost the helper's name and thread ids, and with them the label and the delegation that opens the helper's section.

The prompt is now clipped on its own with the journal's inline-text bound, marker included; the helper's name, ids, model and effort are bounded as before and always survive.
2026-09-30 11:36:57 -07:00
Jinwoo Hong 3047353017 fix(orchestration): worker-abandon settles a stuck worker and records who did it (#23983)
* fix(orchestration): worker-abandon settles a stuck worker and records who abandoned it

worker-abandon refused or no-oped in the states it exists to escape: a stop
stranded by a dead runtime, an active attempt that was no longer the Task's
latest, and settled workers whose terminal release was stuck at requested or
unknown. It now settles every non-terminal worker except this runtime's own
in-flight stop, records who abandoned it, and retains (never closes) an owned
terminal whose release is not already in flight.

Part of STA-8833.

* refactor(orchestration): abandon retains through worker-retain's rule; record cancellations

- One retain helper serves worker-retain and worker-abandon. A committed release (releasing, unknown) keeps its state and archive, since the tab may already be closed; a retained terminal drops its stale archive.
- worker-abandon keeps the published stale field (this attempt was not the Task's current one) on the worker path.
- task-list shows a failed Task's reason, whitespace-collapsed; task-update help and the recovery guide document cancel = failed + --result cancelled.

* test(orchestration): drop duplicate abandon asserts; leave task-list output unchanged

Cancellation stays documented in task-update help and the recovery guide.

* fix(orchestration): abandoning an already-settled worker changes nothing

Only the settling path retains an owned terminal.

* fix(orchestration): attribute abandon only to a verified caller and keep the prior diagnostic

* fix(orchestration): report an already-settled abandon as stale, as main did
2026-09-30 13:41:34 -04:00
Jinwoo Hong 9afd1101ff fix(orchestration): stop minting and printing the dispatch capability (#23994)
* fix(orchestration): authorize worker reports without the dispatch capability

Worker lifecycle reports and questions no longer depend on the per-dispatch
capability token that lives only in the agent's conversation. The host now:

- ignores capability_hash/capability_revoked_at for authorization on every
  row and checks the exact worker process instead (ask gains that check);
- refuses a report whose calling terminal is provably another orchestration
  party (a Run coordinator or another Dispatch's worker), treating env that
  names no live pane here as absent;
- applies one worker-state rule locally and remotely: a stop in flight
  refuses, while stop_unknown and start_unknown accept and settle.

Minting and printing the flag are unchanged, so an older host and older
preambles keep working.

* fix(orchestration): stop minting the dispatch capability

Dispatches no longer mint a per-Dispatch token, and preambles, the bundled
skill guide and the ask resume hint stop printing --dispatch-capability. The
consumer-generation bump and delivery fence that minting carried stay, now
as setDispatchConsumer. Readers that inferred meaning from capability_hash
read what they meant instead: worker-show's injected stage comes from the
attached consumer, and a failed start copies custody identity only when no
authority was ever attached. The CLI keeps accepting and forwarding the
flag for older hosts.

Cancelling a Task is recorded as failed with a reason; task-list now shows
that reason and the guide and task-update notes document the recipe.

* fix(orchestration): name the fenced party without implying which Dispatch it owns

* refactor(orchestration): one worker report rule, fence only a different party

- One module owns the unproven/settleable worker states and the refusal rule; local send records it, ask and remote throw it. A stale process is worker_identity_changed on every path.
- The caller fence passes the worker's own terminal when its --from handle went stale.
- Document the shared-tmux-server limit; drop the dead dispatch_capability_invalid rejection member; tests assert dispatch state, not the capability column.

* refactor(orchestration): drop setDispatchConsumer and the dead capability retention

- dispatch --inject no longer re-points the row createDispatchContext just wrote; worker-show reports every worker-less Dispatch as context_only, since Orca keeps no record of the paste. Tests re-point through a fixture.
- failWorkerStart always records when the lifecycle closed; nothing authorizes on it.
- Restore the ask resume hint's echo of a passed --dispatch-capability: an old host checks it before --resume.
- Move the cancellation convention to #23983.

* test(orchestration): drop capability-era assertions other tests already cover

* refactor(orchestration): drop the host-side capability field and no-op test fixtures

- RpcRequest and the SSH bridge stop carrying orchestrationCapability; the CLI's wire field stays for older hosts.
- Fixtures pass identity to createRootDispatch instead of re-pointing to the same values; drop absence checks for a flag that can no longer be produced.

* test(orchestration): cover a current process whose terminal moved to another pane

* chore(orchestration): finish the capability cleanup in test stubs and skill wording

* test(orchestration): drop needless response casts; mark the db stub cast safe
2026-09-30 13:26:06 -04:00
Jinwoo Hong 46d6b76ae8 fix(orchestration): retry worker_done while the Orca runtime is briefly unreachable (#23984)
* fix(orchestration): retry worker_done while the Orca runtime is briefly unreachable

A worker reports worker_done once and ends its turn, so a few-minute app
outage silently stranded finished work at dispatched. The CLI now retries
worker_done on runtime_unavailable for about two minutes with backoff,
reusing one request id so the host's mutation ledger replays rather than
double-applies it, then prints the existing recovery command.

The contract probe no longer caches a failed status.get, which otherwise
made every retry fail without reaching the app.

Part of STA-8833.

* refactor(cli): pass the worker_done retry window in the mutation options bag

* Revert "refactor(cli): pass the worker_done retry window in the mutation options bag"

The options bag is forwarded to client.call as-is; the retry window is not a client.call option, and folding it in needed a value scan to keep the no-options call shape.

* test(cli): fold the explicit retry-request case and drop a vacuous timing assert

* fix(cli): keep worker_done recovery when the last retry fails before sending

Also skip the Unix-socket retry test on Windows and remove its temp profile.
2026-09-30 13:04:57 -04:00
Jinwoo Hong e03870403e fix(orchestration): accept worker reports without the dispatch capability (#23982)
* fix(orchestration): authorize worker reports without the dispatch capability

Worker lifecycle reports and questions no longer depend on the per-dispatch
capability token that lives only in the agent's conversation. The host now:

- ignores capability_hash/capability_revoked_at for authorization on every
  row and checks the exact worker process instead (ask gains that check);
- refuses a report whose calling terminal is provably another orchestration
  party (a Run coordinator or another Dispatch's worker), treating env that
  names no live pane here as absent;
- applies one worker-state rule locally and remotely: a stop in flight
  refuses, while stop_unknown and start_unknown accept and settle.

Minting and printing the flag are unchanged, so an older host and older
preambles keep working.

* fix(orchestration): name the fenced party without implying which Dispatch it owns

* refactor(orchestration): one worker report rule, fence only a different party

- One module owns the unproven/settleable worker states and the refusal rule; local send records it, ask and remote throw it. A stale process is worker_identity_changed on every path.
- The caller fence passes the worker's own terminal when its --from handle went stale.
- Document the shared-tmux-server limit; drop the dead dispatch_capability_invalid rejection member; tests assert dispatch state, not the capability column.

* test(orchestration): drop capability-era assertions other tests already cover

* test(orchestration): cover a current process whose terminal moved to another pane
2026-09-30 13:01:53 -04:00
Neil d74388f8a2 test: retire cases subsumed by an honestly-named neighbour (#24150)
* test: retire two agent-status ipc cases whose fixture arms reach identical code

Follow-on from the wave-30 sweep: an auditor continued into its own disclosed unread list and
read the `useIpcEvents-agent-status-*` family. 2 case declarations removed across 2 files, 152
lines gone. No production code touched.

- `useIpcEvents-agent-status-queue-ordering.test.ts` — `applies ready push events for an
  unmounted inactive terminal tab`. Character-identical to `applies ready push events for
  inactive terminal tabs with empty layout snapshots` in
  `useIpcEvents-agent-status-hook-titles.test.ts` — same event, same prompt strings, same
  six-argument `setAgentStatus` assertion — except for one fixture field:
  `terminalLayoutsByTabId: {}` versus `{'tab-future': {root: null, ...}}`. The deciding line is
  `agent-status-pane-routing-index.ts:209`, `if (layout?.root)`. Both `undefined?.root` and
  `{root: null}.root` are falsy, so both arms skip the leaf-membership branch and every
  downstream line is identical. The `hook-titles` copy is kept because it has a genuine local
  contrast arm — `buffers ready push events until a mounted tab contains the pane leaf`, with
  `root: {leafId: STALE_LEAF_ID}` producing `exists: false`, buffering and
  `agent_hook_unattributed` telemetry. The queue-ordering copy had no such pairing.

- `useIpcEvents-agent-status-pane-teardown.test.ts` — `does not retain a Cursor spinner terminal
  title when the hook reports done`. Production IS mirrored here
  (`SYNTHETIC_AGENT_TITLE_PROFILES` has 9 entries), so the mirrored-production check applied:
  what differs between the codex and cursor rows is `synthesizeWorkingTitle: false`, and neither
  case asserts it. Both exercise only `done -> idleLabel` through one shared path in
  `resolveAgentStatusTerminalTitle`. The unique contribution is owned verbatim by
  `src/renderer/src/lib/agent-status-terminal-title.test.ts`, same glyph and same string, and the
  Codex survivor asserts a strict superset — it additionally pins `updateTabTitle('tab-future',
  'Codex ready')` called exactly once.

Reported, not fixed: `ipc-events-agent-status-store-test-fixtures.ts:84` hand-copies the
monotonicity rule as `updatedAt < existing.updatedAt`, while production's decision lives in
`agent-status-live-entry-builder.ts` and additionally weighs retirement fences, recently-closed
tabs, pane-authority aliases and per-connection watermarks. Rather than reason about the risk the
auditor instrumented the branch and ran all eight files: never taken, 56 of 56 cases. So it is
dead test-support code today rather than a stale premise — and a trap for the first staleness case
anyone writes in these files, which would pass against the simplified copy. Left unmodified
because the module is shared with three specs outside the audited chunk.

Verified: 172 test files / 1,084 cases pass in `src/renderer/src/hooks`;
`check-reliability-gates.mjs` 140 gates; neither deleted title appears in the gate manifest; both
named owners confirmed present.

* test: retire two runtime cases subsumed by an honestly-named neighbour

Final chunks of the 2,269-file backlog, `src/renderer/src/runtime`. 2 case declarations removed
across 2 files, 30 lines gone. No production code touched.

Both deletions share a shape: the surviving neighbour is not merely equivalent, it is the case
whose NAME tells the truth about what the code does.

- `paired-reconnect-sidebar-agent-count.test.ts` — `the erased rows are exactly the panes whose
  status only the client wrote`. Two defects at once. It computes `missing` by filtering the same
  `reconnected.rowPaneKeys` that the immediately preceding case already asserts equals the full
  pane set, so `expect(missing).toEqual([])` cannot fail where that case passes. And its title
  promises a characterisation its own assertion denies: it claims the erased rows ARE the
  client-only panes, while asserting the erased set is empty. Its comment describes a "causal
  boundary" that `toEqual([])` never establishes. Owner: `keeps a sidebar row for every still-live
  pane after a long sleep`, immediately above.

- `runtime-terminal-inspection.test.ts` — `can record a runtime input marker from a PTY id
  mapping`. Its fixture sets `experimentalAgentHibernation: true`, and that flag appears ZERO
  times in `runtime-terminal-inspection.ts` — `recordRuntimeTerminalInputForPtyId` resolves a pane
  key and records, reading no setting. The surviving case sets the same flag to `false` and asserts
  the identical `toBe(123)` from the identical call, so the two differ only in an inert field.
  Owner: `records runtime input markers even before hibernation is enabled`, whose name documents
  the flag-independence the pair accidentally demonstrates.

Verified: 209 test files / 1,703 cases pass in `src/renderer/src/runtime`;
`check-reliability-gates.mjs` 140 gates; neither deleted title appears in the gate manifest.

* test: retire cases whose varied field production returns from one branch

Final chunk of the backlog sweep, `renderer/runtime` tab-sync. 4 case declarations removed
across 2 files, 57 lines gone. No production code touched.

- `web-session-tabs-sync-agent-handoff.test.ts` — `keeps stale local agent tabs when the host
  mirror is for a different agent`. `shouldReplaceTerminalTab` has no agent-kind branch, and
  `launchAgent` appears ZERO times in `terminal-surfaces.ts` — its own comment says "agent kind
  is not session identity". Both this and its sibling enter the same `exactProvisionalHandoffs`-empty
  branch, and the sibling (same-agent) is the one that would go red if agent-kind matching were
  reintroduced. This one cannot.

- `web-session-tabs-sync-client-owned-page-content.test.ts` — three cases asserting `loading`,
  `canGoBack` and `canGoForward` individually. `resolveMirroredBrowserPageContent` returns all
  five fields from a single `if (clientHostsMirroredBrowserPage(tab) && existingPage)` branch, so
  no production path preserves one and drops another. Six per-field cases ran off a byte-identical
  fixture. Kept the `title` case (the reported bug) and the `url` case (production documents url
  as independently load-bearing for the next snapshot's url-equality arm), plus two cases the
  per-field reads cannot cover: one asserting different state slices, one asserting patch-key
  omission.

Reported, not fixed: `web-runtime-session-tab-activate-close.test.ts` asserts a stale-terminal
refusal is indistinguishable from a real close, while its own comment says "Any fix must make
these two outcomes distinguishable". As a plain `it` it will go red when the bug is fixed and read
as a regression; `it.fails` is the honest shape. Second instance of that pattern in this audit.

Verified: both files green (24 cases); `check-reliability-gates.mjs` 140 gates; neither deleted
title in the gate manifest.
2026-09-30 06:47:16 -07:00
Neil fa3710df5a test: retire cases whose fixture decides the outcome it asserts (#24144)
Backlog chunks 12-17: sidebar, hooks and four renderer/lib chunks, six auditors at 84 files
each. 38 case declarations removed across 27 files (56 executed cases, one deletion was a
13-entry `it.each`), 1 test file deleted, 710 lines gone. No production code touched.

The theme this wave is a test whose own scaffolding makes the decision it claims to check:

- `useAutoAckViewedAgent.test.ts` — its `runAutoAckScan` helper reassembles the hook's scan
  loop, calling `resolveAutoAckTabTargets`, `createTerminalAttentionSurface`,
  `resolveViewedUnreadSubjectKey`, `shouldClearWorkspaceAttention` and
  `applyAgentAttentionAcknowledgement` in production's order. The test, not the hook, decides
  the outcome. Owner drives the real hook through `renderHook`/`rerender`.
- `WorktreeList.lineage-agent-expansion-coupling.test.tsx` — a CONTROL case that was green
  both before AND after the fix it brackets. Expansion now lives in a module-level cache
  (`worktree-card-agents-expansion-state.ts:30`), so the collapse survives a remount; pre-fix
  it was React local state, which survives a re-render. The two arms it claims to contrast
  never reached different code.
- `agent-paste-draft.test.ts` — a self-comparison over lazily-chunked draft arrays.
- `remote-workspace-session-merge-local-survival.test.ts` — its own comment concedes the tab
  survives, and the assertion loops `expect(['agent','closed']).toContain(tab.id)` over a
  fixture containing only those two ids, so it cannot fail except on a fabricated id.

Also removed: duplicate invocations where production reaches the asserted branch before the
varied input is read (`resolve-zoom-target.ts:43-45` returns `'ui'` for browser tabs before any
focus signal); a telemetry fallback already covered by the `it.each` row for the one call site
that names no source; and `lazy-with-retry.right-sidebar-syntax-error.test.ts` whole, whose two
cases drive one branch with `')'` vs `']'` while the owner cites the same crash report
(e08749bb) and asserts the same pair.

Two corrections to my own guidance came out of this wave, both from auditors measuring what I
told them:

1. The widening-cast "detection signal" I introduced last wave is NOT greppable. An auditor
   checked all 200+ `as never`/`as unknown as` hits in its chunk and found zero
   function-signature widening; repo-wide, `as unknown as` matches 192 renderer/lib test files
   and the narrow `as (...args: unknown` form matches 26 that are nearly all the legitimate
   idiom for forwarding to a real implementation while partially mocking a module. Whether the
   cast target is the SUBJECT or a COLLABORATOR is semantic, not syntactic. Downgraded to a
   reading aid.
2. That is the ninth syntactic proxy I have proposed for a relationship that exists only
   between a test and its production counterpart, and the ninth to fail measurement. No such
   proxy exists; reading the case against production is the method.

Two new defect classes recorded, both invisible in the test's own text: a FIXTURE that
reimplements the production rule (`runtime-session-mirror-unverifiable-host.test.ts:47` builds
inputs with the same `verification === 'verified' && !retired` expression production uses at
`runtime-status-snapshot.ts:28`, so the test keeps passing against a stale premise if the rule
changes — already diverging, since a sibling projection adds a third condition); and the stale
CONTROL above. With anti-vacuity, that is three ways a test can silently stop testing what it
claims while staying green.

Coverage is partial and stated as such: 54 to 84 of 84 per chunk, every auditor listing its
unread paths. `renderer/lib` files are larger than earlier areas, which is where the gap comes
from.

Verified: 1,076 test files / 9,765 cases pass across the touched areas;
`check-reliability-gates.mjs` 140 gates; the deleted file is absent from the gate manifest,
`cloud/package.json` and `mobile/tests-typecheck-baseline.txt`.
2026-09-30 06:15:28 -07:00
github-actions[bot] 8cc28545a2 Update README downloads badge 2026-09-30 12:44:27 +00:00
Neil 1f8159cca6 test: retire cases whose named dimension the production signature cannot express (#24139)
Backlog chunks 06-11, six auditors at 84 files each. All six read their full scope
case-by-case against production — the second consecutive wave with no disclosed gap. 68 case
declarations removed across 49 files, 2 test files deleted, 1,159 lines gone.

The sharpest deletion is also the audit's best detection signal.
`getFolderWorkspacePrimaryActionLabel(): string` takes zero parameters and returns a constant
`translate(...)`. Its test, titled "uses a stable workspace creation label independent of
quick agent selection", called it as

    (getFolderWorkspacePrimaryActionLabel as (...args: unknown[]) => string)({ id: 'codex' })

The cast is the evidence: the author had to defeat the type system to express the premise,
because the dimension the title names cannot reach the function. That shape is greppable,
and it is already against house style — AGENTS.md permits no type assertions but `as const`,
and "production accepts an argument it does not declare" is not a defensible SAFETY
rationale.

Other removals, each with the owner named in the report:

- `metaKey`/`ctrlKey`/`shiftKey` varied across 8 cases against an onClick handler that calls
  only `stopPropagation()` and `onOpenHostedReviewInChecks()` and inspects no modifier;
  GitHub and GitLab share one branch there.
- `resolveVisibleCreatePrHeaderAction` is `return createPrHeaderAction`, an identity function,
  behind two titles naming "the body composer is open" — a dimension its one-field signature
  cannot express. Whole file.
- `statPath` returning `{isDirectory:false}` means the `loadDir` branch is never entered, so
  "falls back when directory loading fails" is unreachable.
- A "hide sleeping" case that searched a test-local literal array which itself hardcodes the
  key being looked for, so the knob was always defined.
- `updater.startup-scheduling`: production has no platform branch — `verifyUpdateCodeSignature`
  appears only inside a security comment — so the darwin case asserted the same absence as the
  win32 case. The win32 case stays; it is what makes that comment fail CI.
- Logical subsumption rather than textual: a case asserting `shallow` equality where its
  neighbour asserts `toBe` on identical fixtures. `toBe` implies `shallow`, so it cannot fail
  where the survivor passes.
- Replays across bare re-exports and one-line adapters, including a 3-case describe over
  `return selectWorktreeAgentOrchestration(state, worktreeId)` whose owner runs 300 seeds
  against an independently transcribed oracle.

One production line goes: a test-only `export { getSetupGuideSidebarEntryReady,
shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry'` in `SidebarNav.tsx`. Both symbols
now appear only at their definition site, with zero importers.

Kept after checking production rather than shape, and reported: GitHub and GitLab
ready-for-review pairs that route through different APIs and negotiate different capability
tokens; the `windows-process-tree-kill` / `windows-live-tree-kill.win32` pair, whose win32
header argues the duality including a disclosed refusal-orphans-descendants asymmetry; and a
fake-`closest` test that looks like a previously-deleted shape but whose predicate really does
read the attribute the test sets.

Verified: 5,005 test files / 50,332 cases pass across the touched areas; `pnpm tc` clean after
clearing `.tsbuildinfo`; `check-reliability-gates.mjs` 140 gates; both deleted files absent
from the gate manifest, `cloud/package.json` and `mobile/tests-typecheck-baseline.txt`.

Four failing files were checked and none is in this diff: `session-scanner-codex-workers`,
`browser-manager-tab-identity` and `browser-manager-viewport-ownership` fail identically on a
pristine `origin/main` worktree, and `structured-chat-coordinator-mail` is a load-sensitive
`vi.waitFor` that passes in isolation and on CI re-run.
2026-09-30 05:43:52 -07:00
Neil 85f8d6b5f5 test: retire long-tail cases whose assertion is decided by the test itself (#24132)
Resumes the backlog sweep at a chunk size that actually gets read. Six auditors, 84 files
each, and all six read their full scope case-by-case against production — the first wave
where every chunk closed with no gap. 33 case declarations removed across 22 files, 1 test
file deleted, 826 lines gone. No production code touched.

This wave exists because a conclusion of mine was wrong. I had recorded that yield collapsed
~36x and that deletion was no longer the high-value work. I was dividing cases removed by
files IN SCOPE while the fraction auditors actually READ fell from 100% to about 4%, because
I kept handing them 300-800 files. Recomputed against files read, yield has been flat at 4-7
per 100 with no downward trend. This wave came in at 8.2.

The most instructive removal looked like the most valuable test in scope.
`orchestration-worker-release-reap-fixed.func.test.ts` cites a production bug by two
identifiers, describes orphaned PTYs accumulating until `TasksMax=4096` aborts processes on
EAGAIN, and advertises itself as the functional tier wiring the real orchestration RPC
surface, the real `OrchestrationDb` and the real release modules. Deleting it leaves no
reference to that bug anywhere in `src`.

It still had to go: its fake runtime performed the fence it asserted —

    if (pty.incarnationId !== inc) { return null }
    handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch })

— so the case checking that a reused ptyId with a mismatched incarnation does not resolve was
checking a decision its own spy made twenty lines earlier. The real fence is owned by
`orca-runtime-terminal-handle-incarnation.test.ts:257`, and the other two cases replay
`orchestration-worker-release-incarnation-fallback.test.ts` (which uses a plain
`mockReturnValue` rather than reimplementing the remint) and `worker/worker-release.test.ts:23`.
"Integration test" and "wires real modules" describe the scaffolding, not the asserted step.

Other removals: a self-comparison disguised by an alias, where
`export const getIssueOwnerRepo = getOwnerRepo` makes a case asserting the two "agree" into
`f(x) === f(x)`; four cases whose `vi.mock` of `resolveIssueSource` made both the preference
value and the topology inert; five verdict-precedence cases owned by a verdict-agnostic block;
three call-shape probes on one-line store pass-throughs whose real contracts are driven by
behavioural neighbours; and a `export type _Ref = [...]` declaration whose own comment admits
it exists only to preserve test-only module-surface references.

Kept after checking production rather than shape. An auditor found two near-identical
ten-reconnect loops and kept both: one uses a test-local live-lease filter, the other the
shipped `sshRemotePtyLeaseAllowsReattach` predicate, and the file's own comment explains the
duality is deliberate "so the two cannot drift". Another kept a paths-alignment case that
looks like a validator tested against its own list, because adding a generated file without
registering its path does fail it — and `shellReadyWrappersExist` uses that registered list to
decide whether a partial tree needs regeneration.

Production duplication is now confirmed four times over, and it is why mirrored tests exist:
`createUpdateWorktreeLineage`/`createAssignWorktreeParent` differ by one `console.error`
string; `terminal-path-tap.ts` and `document/path-tap.ts` carry hand-maintained copies of
`matchFilePathAtColumn` under a docblock reading "keep the two in sync". In those cases both
test sides are load-bearing and the duplication belongs on a refactor list.

`mobile/tests-typecheck-baseline.txt` loses one entry. Trimming
`relay-host-signed-out-verdict.test.ts` made it typecheck clean, so the ratchet required
pruning its grandfathered entry — the file graduates from exempt to enforced. Baseline is now
124 entries, down from 125.

Verified: 690 test files / 7,560 cases pass across the touched desktop areas; the modified
mobile files pass (162 cases); `check-tests-typecheck-ratchet.mjs` OK (898 files in program,
124 grandfathered); `check-reliability-gates.mjs` 140 gates; the deleted file is absent from
the gate manifest, `cloud/package.json` and the mobile baseline; nothing under
`mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.
2026-09-30 04:58:31 -07:00
Neil 0c4b336c16 test: close the disclosed reading gap in agent-hooks, claude and store slices (#24126)
Reads the 111 files that wave-26 auditors named as NOT REACHED in their own
disclosures — the tracked remainder every prior wave ended with. Three chunks of 37,
sized so finishing was achievable rather than optional. 8 case declarations removed
across 5 files, 222 lines gone. No file deleted whole, no production code touched.

The first chunk read all 37 of its files case-by-case (15,440 lines) and found one case:
a strict subset of a sibling in the same file with an identical event sequence
(UserPromptSubmit, two SubagentStart, Stop, child PermissionRequest, lead PreToolUse,
staggered SubagentStop) and byte-identical final assertions, differing only in a prompt
string and a child-id spelling. The surviving sibling additionally asserts
`mainAgentState === 'working'` after the first child stops, so it is the stronger owner.

The rest are duplicate invocations and cases whose input cannot reach the behavior they
name, in `src/main/claude` and the renderer editor/worktree slices.

The more useful result is a KEEP that corrects a rule I gave these auditors. A case
iterating the production constant `CURSOR_EVENTS` looks exactly like the
validator-tested-against-its-own-list pattern, and would have been deleted under the rule
as I wrote it. It is legitimate: its per-entry expectation is a test-local map closed with
`satisfies Record<CursorEvent, string>`, so adding a production event leaves a missing key
and removing one leaves an excess key — either fails typecheck. The drift the tautology
version cannot detect is caught here by the compiler rather than the assertion, and the
loop still asserts the installed shell command carries that exact literal.

That gives the pattern three variants and one question that resolves all of them: if
production changed, would anything fail — an assertion, or the typechecker? Syntax does
not answer it; provenance and enforcement do.

Also kept, each verified rather than assumed: a generated-script test asserting guard
ORDERING by `indexOf`, which is the only thing catching the #11549 hang class and the
#2426/#15462 deny-by-silence class, and which no behavioral test can reach; two WSL suites
that partly overlap but drive different layers, since only one reaches
`WslHookRelayManager.ensureForDistro` for distro ownership and generation dispose/replace;
and a thin Amp case kept because a single-path ripgrep confirmed `hasExplicitPrompt` for
that provider is asserted nowhere else in the repo.

Reported rather than acted on: a Grok case living at the end of
`server-copilot-normalization.test.ts`. Misfiled, not junk, and HARD RULE 1 forbids moving
it.

No case was found where both a test and its supposed owner were inert.

Verified: 601 test files / 5,951 cases pass across the touched areas;
`check-reliability-gates.mjs` 140 gates; all 8 deleted case titles independently grepped
against the gate manifest with zero hits.
2026-09-30 04:23:51 -07:00
Neil db73d28e51 test: retire backlog cases that assert a shim, a literal, or an unread branch (#24120)
Deep-reads the 596 files earlier auditors explicitly disclosed as reviewed at
title-and-import level only, never against production. Six ~100-file chunks, chosen so
depth was achievable rather than optional. 27 case declarations removed across 19 files,
1 test file deleted, 522 lines gone. No production code touched.

Two chunks found nothing, and that is reported as the result rather than padded:
`src/main/agent-hooks` (89 files, 50 read case-by-case in full) returned zero deletions;
`src/main/claude` (90 files, 44 read in full) found three near-identical pairs via a
normalized case-body hash and kept all three after reading them.

What went:

- Identity copiers with a type-level title. `ui-new-workspace-draft.test.ts` (deleted, 3
  cases) tested `setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft })` — a
  bare pass-through — by passing a literal in and asserting a subset of that literal back.
  Its titles named field-shape facts that the typed parameter at
  `ui-slice-contract-core.ts:197` already enforces in production, so the annotation that
  does the work lives in production, not the test.
- A test that asserted its own mock. `destroyRemovedBrowserWebview(id)` is literally
  `destroyPersistentWebview(id)`, and the case mocked `destroyPersistentWebview` — so it
  checked that the mock received the argument the shim passed through.
- Replays across a bare re-export. `pane-tree-ops.ts:18` is
  `export { equalizePaneSplitSizes, findPaneChildren } from './pane-tree-equalization'`;
  two cases used `MockHTMLElement` with no dividers while the owner asserts concrete flex
  values on real happy-dom elements and runs a 400-seed differential against an
  independently reimplemented weight walk.
- Table rows varying a value production never branches on.
  `mergeCurrentOrchestrationContext` tests only `dispatchStatus !== undefined`, so
  `it.each(['failed','circuit_broken'])` ran the path the surviving `'completed'` case
  already proves. Per-value behavior is owned by the one consumer that reads those
  literals.
- Duplicate invocations whose surviving sibling asserts strictly more, including a push
  error case differing from its neighbour only in which verb produced the same error
  string.

Kept deliberately: bound and cap guards (a 50-entry nav-history cap, a skill-cache
eviction pinned to size 2 after 512 retired runtimes, a cleanup-concurrency ceiling);
`buildDefaultTerminalOptions` cases restating declared constants, because each records a
cross-cutting UX decision whose comment preserves the v1.4.51 ZWJ table-corruption history
that once forced scrollbar width 0; three per-grammar `tokenizer.root` invariants for
astro, svelte and vue, which are separate grammars rather than replays; and a test reading
`@xterm/{headless,xterm}/package.json` out of node_modules to prove both were built from
the same upstream commit, which is legitimate because the installed package is the shipped
contract.

Six `src/shared` files were trimmed, and each was checked against the risk that matters
there: `src/shared` is the OWNER four earlier waves deferred to when deleting
main/renderer/mobile tests, so hollowing one out would orphan several callers at once.
Each retains 9 to 46 cases after removing 1 or 2.

Coverage is partial and stated as such. Read case-by-case: pane-manager 81 of 81, slices-a
96 of 121, slices-b 94 of 121, agent-hooks 50 of 89, claude 44 of 90. Every auditor listed
the paths it did not reach.

Verified: 1,294 test files / 14,257 cases pass across the touched areas, plus one
pre-existing `it.fails` marker; `check-reliability-gates.mjs` 140 gates; the deleted file
is absent from the gate manifest, `cloud/package.json` and
`mobile/tests-typecheck-baseline.txt`.
2026-09-30 03:59:58 -07:00
Neil a781a602a8 test: retire duplicate cases that replay an owner across a re-export or provider shim (#24114)
Resolves 208 candidate pairs where the same case title appears verbatim in two or more
files, produced by a repo-wide scan calibrated against a known positive. 46 case
declarations removed across 32 files, 798 lines gone. No file deleted whole, no
production code touched.

The headline result is the measurement, not the deletions: across the three buckets that
reported in detail, the signal ran roughly 86% false-positive (3/42, 9/42, and the rest).
It has good recall and poor precision, and it reorders a reading queue rather than
replacing one. Calibrating a detector against a known positive proves recall, not
precision.

What the deletions were:

- Duplicate invocation through a re-export shim. `native-chat-tool-summary.ts` is a
  ten-line `export {...} from '../../../../shared/native-chat-tool-summary'`, and
  `agent-status.ts:161` is `export { isExplicitAgentStatusFresh } from
  './pane-agent-evidence'`. Cases on the shim side were byte-equivalent to the owner's
  with no rendering or transport hop.
- Provider-local replays of a shared helper: three `repository-ref` providers that are
  each `createRemoteRefProbeCache(parseXRef)` and contribute nothing to transient
  handling; two `local-pty` and `daemon/session` tables replaying
  `shell-startup-output-scanner`, whose owner additionally checks every split point.
- A reader-side replay of store policy. `runtime-worktree-agent-rows-structured.test.ts`
  asserted an attention-to-blocked mapping; the reader contains zero `attention` or
  `blocked` tokens and copies `state` through. The mapping lives in
  `structuredAgentSessionAgentStatus`. Consistent with
  `docs/reference/agent-status-store.md`: readers keep only presentation policy.
- Constructor-only subclass duplication: the shared capability-cache case is covered by
  `codex-app-server-capability-cache.test.ts`, whose ten cases include the identical
  title plus all four risks `docs/reference/git-compatibility.md` names — first fallback,
  later cached call, concurrent probes, per-host isolation.
- A private predicate duplicated at a real boundary, varying only a path passed straight
  into the shared predicate.

Why most pairs were KEPT, because the false positives are principled rather than noise:

- Two independent execution hosts. `src/relay/git-handler-*` and `src/main/git/*` are
  separate Git implementations that cannot import each other and hold separate capability
  caches, exactly as the compatibility doc requires; the repo already ships
  `status-branch-line-total-relay-parity.test.ts` to pin the duality deliberately. Neither
  side's argv, timeout or cache regression is visible to the other.
- Deliberately duplicated production siblings: Codex vs Claude (different account fields,
  different CLIs, different wire protocols), gitea vs bitbucket (`/pulls/42` vs
  `/pullrequests/42`), gl-utils vs gh-utils (separate in-flight maps). Same contract
  shape, different implementations — an identical title is the correct naming.
- Shared-predicate consumers: one side tests the predicate, the other tests a caller's
  wiring to it. A caller that forgot to call the predicate passes the shared test.

In a codebase with intentional provider and host symmetry, identical test titles are
expected, and the signal cannot distinguish "copied" from "parallel by design" because
both produce the same prose. Only reading both bodies separates them.

Verified: 6,968 desktop test files pass; the three modified mobile files pass (39 cases);
`check-reliability-gates.mjs` 140 gates; nothing under
`mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.

62 local failures across 12 files were each accounted for and none is caused by this
change: `browser-manager-tab-identity`, `browser-manager-viewport-ownership`,
`session-scanner-codex-workers` and `managed-hook-script-refresh` all fail identically in
a pristine `origin/main` worktree; five `mobile-web-app-*-render` tests need Playwright
browsers this machine lacks; `structured-agent-session-restart-ownership` and
`ssh-remote-commands` pass in isolation and fail only under concurrent load.
2026-09-30 03:30:23 -07:00
Neil d2dbe2c385 fix(windows): replace the managed CLI launcher with a native one (#24094)
* docs(security): add the antivirus clearance path for future releases

Every AV false positive here has been handled one vendor and one shipped
version at a time. Document the programs that clear future releases instead --
signer and product enrollment rather than per-build sample submission -- and add
a script that reports an RC's current detection state by hash, so a verdict is
found before users meet it in an issue report.

Hash lookup only by default; --upload transmits the artifact and stays manual.

* fix(windows): replace the managed CLI launcher with a native one

resources\bin\orca.exe was a csc-compiled MSIL assembly: a small, freshly
compiled .NET image in a user-writable directory that mutates environment
variables and proxies a child process. That is the shape .NET dropper
heuristics are trained on, and every verdict against it named the family --
MSILHeracles from two vendors, Wacatac!ml from a third. Signing the file does
not change its shape, so signing never cleared it.

Rebuild it in Rust. Same resolution, same environment contract, same argv
passthrough that keeps newline-bearing orchestration bodies intact (#8374), and
the child still inherits our environment block rather than an explicit map, so
a block carrying both PATH and Path survives (#12046). The PE now carries
publisher, version, icon and an asInvoker manifest from build.rs.

Refs #23383

* ci(windows): install the Rust toolchain before building the CLI launcher

The hosted runners happen to ship cargo, but a real Windows dev box does not --
verified on our own Windows QA host, where cargo and rustc were both absent.
Relying on the image means a future image change fails deep inside
electron-builder's native hook instead of at an obvious step.
2026-09-30 02:49:03 -07:00
Neil cef66fbab8 test: retire long-tail cases whose input cannot reach the behavior they name (#24101)
Sweeps the triage-only backlog: 2,269 files that earlier waves saw and skipped for
size, reconstructed from the unread lists five waves of auditors disclosed. 35 case
declarations removed across 15 files, 2 test files deleted, 487 lines gone. No
production file touched.

These are large integration suites, so the junk here is individual cases buried among
real coverage rather than whole bad files. The dominant defect was again a case whose
input cannot reach the behavior its title names:

- `resume-sleeping-agent-session-remote-compat.test.ts` (deleted) — two cases titled
  for "transport-level host authority on a capable host" and "host authority is not
  known". `resume-sleeping-agent-session.ts` has no host-authority or capability
  concept at all, and its only read of `origin` is
  `if (!record.origin && record.state === 'done')`, unreachable for both rows. Both
  executed one identical path. The surviving contract is owned by
  `resume-sleeping-agent-session-execution-host-scope.test.ts`, which drives a real
  host catalog.
- `project-group-header-drag.test.ts` (deleted) — four cases setting
  `data-project-group-header-id`, which the predicate never reads. Its subject,
  `isProjectGroupHeaderActionTarget`, is byte-identical to `isRepoHeaderActionTarget`
  apart from the function name and imports the same `REPO_HEADER_ACTION_SELECTOR`, so
  all four cases were a strict subset of `project-header-drag.test.ts` using identical
  `data-repo-header-*` fixtures.
- `remote-worktree-history-cleanup.test.ts` — "repeats idempotent cleanup through the
  PTY owner" against a six-line best-effort forward with zero dedupe state. The case
  called it twice and asserted the mock recorded two calls, which is arithmetic over
  the test's own loop; nothing about idempotence was established.

Also removed:

- Runtime assertions of type-level facts, where production already makes the check at
  a stronger boundary: `const adapterSatisfiesPort: AdapterIsPort = true` followed by
  `expect(...).toBe(true)` — unconditionally true, while
  `createExpoGenerationFileSystem(): GenerationFileSystem` is explicitly annotated and
  passed into `createGenerationStore` at a typed call site. And a case named "does not
  typecheck" whose runtime assertion is a length check on its own literal, declaring
  its own local annotation so it could never notice the production annotation
  weakening.
- Private predicate tests duplicated at a real boundary: four `repo-slug-cache` cases
  delivered by `repo-slug-index.test.ts`, which drives the same resolution through the
  hook, the real store and the preload bridge, while the cache-level versions hand-seed
  the internal map and break on a cache-key format change.
- Duplicate invocations owned at the shared boundary, including commit and push
  recovery cases owned by `src/shared/source-control-recovery-agent-command.test.ts`.

Kept deliberately, verified rather than assumed: the production duplication behind the
deleted drag test was left alone, because `REPO_HEADER_ACTION_SELECTOR` ends in generic
`button, a, input, textarea, select`, so genuine action targets inside a group header
still match — it is an unspecialised copy-paste, not a live bug, and collapsing two
functions is a refactor. Reported instead.

Auditors' probes produced 20, 11 and 13 candidate hits for the signature-versus-title
shape across their chunks; every one was inspected and every one was genuine coverage.
No deletion in this wave rests on a probe alone.

Coverage is partial and stated as such: of 2,269 files, roughly 100 were read
case-by-case and the remainder reviewed at title-plus-import level. Each auditor listed
its own unread set. The largest remaining surfaces are `src/main/agent-hooks` (95),
`src/main/claude` (100), `src/renderer/src/lib/pane-manager` (62) and the 20 largest
sidebar suites.

Verified: 2,583 desktop test files / 25,636 cases pass, plus one pre-existing
`it.fails` marker; the two modified mobile files pass (57 cases);
`check-reliability-gates.mjs` 140 gates; `check:code-quality:changed` 0 new findings.
Both deleted files confirmed absent from the gate manifest, `cloud/package.json` and
`mobile/tests-typecheck-baseline.txt`.
2026-09-30 02:45:32 -07:00
Neil 23a874a2a1 fix(secrets): seal credentials on Linux desktops Chromium cannot detect (#24035)
* fix(secrets): stop telling Linux users to install a keyring they already run

On a desktop Chromium does not recognise (Hyprland, sway, river, niri) the
selected backend is basic_text and sealing is unavailable, so the at-rest
protection report told the user to install and unlock gnome-keyring — which is
usually already running and serving org.freedesktop.secrets. Chromium simply
never looked, because it picks the backend from XDG_CURRENT_DESKTOP.

Split the Linux unavailable path on the selected backend: an unrecognised
desktop now says so, names XDG_CURRENT_DESKTOP, and points at
--password-store. A backend that did resolve but cannot seal keeps the
install-and-unlock text, which is correct there.

The backend read stays after isEncryptionAvailable(), the call that performs
the D-Bus probe, so nothing new blocks (STA-5765 timing unchanged).

* fix(secrets): seal credentials on Linux desktops Chromium cannot detect

Chromium picks its os_crypt backend from the desktop-environment env vars and
recognises none of the tiling compositors (Hyprland, sway, river, niri). On
those it selects basic_text, whose key Electron only exposes after an explicit
setUsePlainTextEncryption() this app never calls — so isEncryptionAvailable()
is false and every credential store takes its plaintext fallback, while
gnome-keyring sits on the session bus unasked (#21827).

Name gnome-libsecret ourselves, but only where it provably cannot hurt:

- Never for a desktop Chromium does resolve. Overriding a working selection is
  the one change that could strand already-sealed credentials, and a KDE
  session identified only by KDE_FULL_SESSION is the case that matters.
- Only when the secret service's default collection is present AND unlocked,
  measured out of process with a killable 1.5s deadline. A locked collection
  with no unlock prompter is what made isEncryptionAvailable() block for 76s to
  first window (STA-5765); selecting libsecret there would trade silent
  plaintext for a frozen app. The probe reads the Locked property, which cannot
  itself trigger a prompt.

Anything unexpected — no session bus, no gdbus, no name owner, timeout — leaves
Chromium's own choice alone, so the worst case is today's behaviour.

The desktop-presence env vars come from a review finding by @Raajik on #21831,
confirmed there against base/nix/xdg_util.cc.
2026-09-30 02:36:22 -07:00
Neil 5fa290fa50 feat(secrets): warn in Settings when a credential is stored unencrypted (#24048)
* feat(secrets): warn in Settings when a credential is stored unencrypted

When no OS keyring is usable, the MiniMax stores write the credential as a
plaintext envelope and say so with a console.warn nobody reads. The users this
affects are exactly the ones who never see a main-process log, so in practice
they were told nothing (#21827).

Report it where the credential is managed instead. Each store gains a
protection reader, the status IPC carries it, and Settings renders a warning
next to the credential it applies to.

Keyed on the stored bytes, not isEncryptionAvailable(): a credential saved
before a keyring existed stays plaintext until it is saved again, so reporting
current capability would call it protected while the file says otherwise. The
readers parse the envelope kind without decrypting, so opening Settings cannot
provoke a keychain prompt.

The console.warn stays. It carries no secret material, and it is still the only
signal on a headless host with no Settings window.

* feat(secrets): extend the unsealed-credential warning to every affected store

The speech key, Linear tokens, Jira tokens and the Bitbucket credential have
the same plaintext fallback the MiniMax stores do, and the same console-only
warning nobody reads.

Add a shared `readCredentialFileProtection` for the four stores that write bare
ciphertext with no envelope, classifying with the same printable-UTF-8 test
`readStoredCredentialToken` already uses — so the reporter cannot drift into
disagreeing with the reader about the same bytes.

Linear and Jira report across every stored workspace/site rather than the
active one: sealing is a host-wide property, so a second workspace stored while
the keyring was missing is exposed even when the active one is sealed. Both
fields are optional, so an older remote host that omits them reads as unknown
rather than as sealed. Bitbucket reports null for env-supplied auth, where Orca
stores nothing and has no claim to make.

Also fixes the credential-connection test double, whose identity-function
`encryptString` wrote a readable token — faithful enough for a round-trip
assertion, but it made the suite assert that a sealed credential was exposed.

* chore(i18n): extract the unsealed-credential notice strings

CI's localization-extraction gate requires every translate() key to exist in
the primary catalog. Inserted in place rather than re-sorting the file, which
is not fully sorted and would have produced a 17k-line diff.

* test(web): pin the null protection fields on the desktop-only MiniMax bridge

The web bridge reports no protection because it stores nothing; the shape
assertions had to move with it.
2026-09-30 02:13:03 -07:00
Neil b99462ac1c test: retire mobile, cloud, config and e2e cases their input cannot reach (#24077)
Completes the first pass over every test area in the repository. Sweep over
`mobile/src`, `config/scripts`, `cloud/`, and `tests/` (1,494 files in scope, with
the 24 files under `mobile/src/test-support/rpc-recording/` deliberately excluded).
31 case declarations removed across 17 files, 2 test files deleted, 356 lines gone.

What went, by pattern:

- Cross-boundary replays of a shared helper. A whole mobile file re-ran
  `extractPendingAsk`/`parseAskFromStatus`/`formatAskAnswer`, all owned by
  `src/shared/native-chat-ask.test.ts`, `native-chat-ask-fifo.test.ts` and the
  renderer's interactive-prompt suite — one case title was verbatim identical to the
  owner's, and the owners' inputs are supersets. The mobile file imported the shared
  module directly and exercised no mobile transport, lifecycle or rendering.
- A case whose input cannot reach the behavior its title names: "arms it on Android
  while the drawer is open", where `use-back-claim.ts` has zero
  Platform/OS references, so flipping the mocked OS changes only shadow styles.
- Identity copiers, including one asserting `prSidebarRenderBranch(state) ===
  state.kind` against a production body that is `return state.kind`. The function
  stays; it has three live callers.
- A test of the runtime rather than the product: a case asserting Node's own
  `EventEmitter` crash contract on a bare emitter, with zero production code in the
  path. The guard it documents is exercised behaviourally by the case after it.
- Duplicate invocations, one of them provable rather than eyeballed: with
  `MODULE_SCOPE_ENV_WRITER_PIN = 0`, `files.size <= 0` is strictly implied by the
  sibling's `expect(offenders).toEqual([])`, since a non-empty `offenders` forces
  `files.size >= 1`. The pin's own doc says it may only ever be decreased from 0, so
  it could never become a meaningful bound either. Its policy guidance survives as a
  comment; the file's real ratchet and its regex self-test both stay.
- Expected values produced by the test's own arithmetic, and a p95 case strictly
  implied by a sibling that already pins exact p95 and exact max over a wider range.

One production line goes: the `export` keyword on `assignmentCleanupSteps` in
`cloud/apps/relay/src/assignment-cleanup-steps.ts`. The function itself stays and is
still called internally; only the test-only export was orphaned.

Kept deliberately: everything a gate cites, checked by case title and not only by
file path; a gate-cited case that does not deliver its claim (reported instead — see
below); a cross-version wire cell whose ledger is never invoked, left under the
raised bar for wire coverage; and every limit, bound, quota and provenance guard.

Nothing under `mobile/src/test-support/rpc-recording/` or
`mobile/rpc-foundation/goldens/` was touched — those bytes feed a `recorderSha256`
digest pinning 398 golden recordings.

Verified: `mobile` vitest over the modified mobile files (8 files, 50 cases);
`mobile/scripts/check-tests-typecheck-ratchet.mjs` OK (898 files in program, 125
grandfathered, none @ts-nocheck); relay suite 799 passed; `check-reliability-gates.mjs`
140 gates; both deleted files confirmed absent from the gate manifest,
`cloud/package.json` and `mobile/tests-typecheck-baseline.txt`.

Seven local failures were investigated and none is caused by this change: five
`mobile-web-app-*-render` tests drive `playwright-core` chromium/webkit and need
browsers this machine lacks, `release-checkout.unit.test.ts` needs cross-version git
refs, and `e2e-worker-env-isolation.unit.test.ts` fails identically with its HEAD
content restored — it recurses `tests/e2e` with symlink-following `statSync` and no
depth guard.
2026-09-30 02:05:30 -07:00
NeilandBrian Grablin f4092c06d6 fix(runtime): treat Hermes session start as idle, not a running turn (#24064)
Hermes fires `on_session_start` when a session is opened, switched or reset;
its payload carries no turn. Orca mapped it to `working`, so a freshly launched
Hermes held a fresh first-party `working` row for the whole 30-minute staleness
window: `terminal wait --for tui-idle` never settled against an idle composer,
and the sidebar span a phantom spinner.

Map it to `done` with `sessionBoundary`, matching what Claude and the
compatible-lifecycle providers already do for SessionStart, and let the tui-idle
evidence lane settle on a fresh boundary row. A boundary row claims a new
session owns the pane and awaits its first input, which cannot arrive mid-turn,
so it carries none of the #6011 risk that scoped that lane to DSH; a turn-end
`done` still does not settle.

Fixes #13653.

Co-authored-by: Brian Grablin <5216789+bgrablin@users.noreply.github.com>
2026-09-30 01:25:50 -07:00
Neil f37a84993e test: retire renderer cases whose input cannot reach the behavior they name (#24065)
Audit sweep over `src/renderer` (3,206 test files in scope). 58 case declarations
removed across 37 files, 3 test files deleted, 1,033 lines gone. Nine dead
production symbols removed with them.

The dominant pattern this wave was a case whose input cannot reach the behavior its
title claims:

- `getRemoteBrowserFrameStyle` takes `_metadata` unused and returns a constant
  object. Four cases fed it 958x609, a uniform high-DPI 1998x1218, an uneven
  high-DPI frame, and malformed metadata, all asserting the same constant. The
  function cannot branch on any of them; the runtime rejects server-sized frames, so
  the renderer ignores bitmap dimensions by design. Kept the one case whose name
  admits that.
- `shouldIgnoreTerminalMenuPointerDownOutside` takes `{openedAtMs, nowMs}` and has no
  button or modifier parameter. Two cases named for secondary-button and macOS
  control-click passed byte-identical timestamps. Their titles came from the
  docblock's prose, which describes behavior the CALLER delivers.
- A retry case titled "then Retry re-arms" proved that half by deleting a key from
  its own ref object and asserting it was undefined. The `#6648` budget assertion
  stays; the title now matches what the case proves.

Also removed:

- Cases that cannot fail: three asserting `toBeInstanceOf(Map)` and `size === 0`
  immediately after a `beforeEach` that clears those maps.
- Assertion-free coverage probes, one of which writes a JSON report to tmpdir and
  asserts nothing while its own comment says the sibling case is the gate.
- Copied inventories and export lists, each checked per entry: a 35-name renderer
  git-client export list (every name has 7-40 production references) and a 16-path
  caller census that never verified the callers pass the argument it exists to track.
- Provider-local replays of one factory (`createUsageProviderSlice`), keeping a
  single representative; and a case re-running a shared window-shortcut policy the
  shared suite owns.
- Duplicate invocations, including a `buildNativeChatSendBytes` block whose function
  is `buildNativeChatPasteBytes(text) + '\r'`, with three titles verbatim from the
  paste-bytes cases.
- A slice case whose only assertion reads back `antigravity: null`, a literal in
  `createEmptyRateLimitState()`, under a title claiming a "stable pending key".

Nine production symbols went, each verified to have exactly its own declaration plus
one test reference and zero production callers: `buildNativeChatSendBytes` (its
docblock said "kept for callers/tests" and had none), `isCommandMarkerId`,
`buildNativeChatRenderItems`, `collectToolResults`, `scrapeNativeChatSession`, three
now-orphaned types, and an eagerly-evaluated `WORKTREE_CARD_PROPERTY_OPTIONS`.

One deletion was reverted. An i18n JSX-spacing guard reads `.tsx` source text and
would break under a behavior-preserving rewrite, both true. But all three components
it guards have zero referencing test files, so nothing else catches
`translate('…','Detected from')}` followed by `<code>` rendering as "Detected
fromorca.yaml". The junk patterns describe a test's shape; the ratchet rules describe
consequence, and consequence wins.

Kept deliberately: a randomized parity test measured at 483 of 517 comparisons being
`identity === identity`, because 32 seeds and 2 named cases are genuine differentials
and the fix is a rewrite, not a deletion; a title-tracker parity test whose two sides
are separately-authored implementations that a sibling case proves can diverge; a
Monaco upstream-drift detector that reads the installed package, which is the shipped
contract; and nine bound guards, all confirmed to have live production callers.

Coverage is partial and stated as such: roughly 810 of 3,206 files read case-by-case,
the rest triaged by mechanical scan. Every auditor disclosed its own unread set and
those paths are tracked rather than assumed clean.

Verified: `pnpm test` over all nine renderer chunks — 2,834 files, 25,540 cases, 0
failures (a first run showed 5 failures that did not reproduce and were concurrent
load); `pnpm tc` after clearing `.tsbuildinfo`; `check-reliability-gates.mjs` 140
gates; `check:code-quality:changed` 0 new findings. All three deleted files confirmed
absent from the gate manifest, `cloud/package.json` and
`mobile/tests-typecheck-baseline.txt`.
2026-09-30 01:17:49 -07:00
d68a5be13a fix(claude): run Windows hooks without shell operators (#23944)
* fix(claude): run Windows hooks without shell operators

Keep neutral replies inside the managed entry and payload scripts, repair missing files from managed registrations, and stop using Git Bash discovery to guess Claude's hook shell.

Co-authored-by: latte271 <junghyeyun27@gmail.com>
Co-authored-by: Bing.Z <zzb@gxsmjx.com>

* fix(claude): keep the Windows hook refresh async and its scripts after uninstall

- List windows-hook-files.ts in the CLI project so typecheck passes.
- Refresh the entry/payload pair only from a surviving entry, with an async
  existence check, so startup refresh stays off the main thread on Windows.
- Keep both scripts on uninstall like every other agent; a Claude session
  still holding old settings keeps answering instead of erroring per event.
- A payload that exists but cannot start falls through to the neutral reply,
  and the missing-payload branch exits early for background jobs.
- Update the EDR posture reference for the operator-free command.

* test(claude): run the Windows hook host legs for real

The live Windows host legs never ran: runProcessSync cannot take a string
stdin (it forces encoding 'buffer'), so every leg threw before starting a
host. Use async runProcess, pass PATHEXT (without it Windows PowerShell 5.1
prints nothing and exits 0 for a .cmd path), and name the host in each
assertion. Drop the POSIX pwsh leg: its drive-mapping shim proved nothing
about Windows, and the Windows legs cover both PowerShell hosts.

---------

Co-authored-by: latte271 <junghyeyun27@gmail.com>
Co-authored-by: Bing.Z <zzb@gxsmjx.com>
2026-09-30 01:07:09 -07:00
Brennan Benson 75719bd38b fix(terminal): keep the mouse report format in desktop pane snapshots so phone swipes never type escape text (#23943)
* fix(mobile): preserve host mouse modes for terminal scrolling

* revert(terminal): drop the out-of-band mouse-modes channel

The earlier commit sent the host's mouse tracking and encoding beside
each snapshot and stream frame, and made the phone trust that over what
its replayed bytes say. Where the snapshot text already carries the
encoding this is redundant, and where the text is wrong (a desktop pane
snapshot never writes ?1006h) the host's own mirror is seeded from that
same text, so the side channel asserts the wrong encoding too.

Remove the shared type, the host publication, the wire fields and the
phone's host-modes authority. The phone keeps its guard: while mouse
tracking is on but no replayed byte proved the encoding, a wheel scrolls
locally instead of sending a guessed legacy report.

* fix(terminal): carry the mouse encoding in desktop pane snapshots

Swiping a phone terminal running Codex typed `[M`-style mouse bytes into
Codex's prompt instead of scrolling (#23818). Codex turns on mouse
tracking with the SGR encoding (?1006h). A desktop pane snapshot is made
by xterm's serialize addon, which writes the tracking modes but never the
encoding, so the phone replayed "tracking on, legacy encoding" and sent
legacy `ESC[M` reports that Codex does not parse. The host's own terminal
model is seeded from the same snapshot, so it lost the encoding too.

The pane now mirrors its xterm's mouse encoding from the parser (1006,
1016 and a full reset, with xterm's own set/reset rules) and its snapshot
ends with the matching DECSET, after the alternate-screen switch that
readers keep. Programs on the default encoding get nothing appended.

The phone keeps a guard for hosts without this fix: while a wheel-
reporting tracking mode is on but no replayed byte proved the encoding,
a wheel or swipe scrolls locally and a tap or drag sends no mouse report
(the tap still focuses the keyboard). Click-only tracking (?9h) keeps
its arrow-key scrolling on the alternate screen, and an explicit ?1006l
still sends legacy reports.

* fix(terminal): state the default mouse encoding so legacy mouse apps keep phone input

Snapshots now say ?1006l while a program tracks the mouse with the default
encoding (daemon rehydrate and desktop pane serializer), and the phone
treats a tracking enable seen in live output as proof of its encoding.
Legacy-encoding programs such as vim with mouse=a keep phone scrolling,
taps and drags; replay-only tracking with no stated encoding still sends
nothing. An unproven drag falls back to local selection, and an empty pane
snapshot stays empty.

* chore(terminal): type the mouse-encoding tracker inputs for the low-evidence audit
2026-09-30 01:05:26 -07:00
Brennan Benson 59c05d32b0 fix(rate-limits): stop driving a hidden Codex TUI to read usage (#23806)
* fix(rate-limits): stop driving a hidden Codex TUI to read usage

When the headless Codex usage call failed, Orca opened a hidden
interactive Codex, typed /status and pressed Enter without reading the
screen, then killed it after 15 s. If Codex showed its "Update available"
prompt, that Enter picked "Update now", Codex started its installer, and
the 15 s kill interrupted it, leaving the global install broken.

Drop the hidden-terminal fallback. When the headless call fails with a
non-sign-in error, read the same usage from the HTTP endpoint Orca
already calls on WSL and for the 5-hour window, so a usage check can no
longer answer any Codex startup screen.

Fixes #17415

* test(rate-limits): pin the RPC error when the Codex HTTP fallback fails

Also drop comments that still described the removed hidden-terminal fallback.

* test(rate-limits): use real Response objects in Codex fetcher tests

The changed-code gate rejects the new type assertions this PR added.
2026-09-30 01:04:43 -07:00
Brennan BensonandHarshul Rathod 707d3dc96b fix(chat): decode Claude pastes and report terminal delivery uncertainty (#23788)
* fix(chat): decode Claude pastes and track terminal delivery uncertainty

Keep queued prompts pending while the existing agent status reports work, and check fresh history after a later idle fact. Preserve draft text and distinguish write rejection from unconfirmed delivery.

Co-authored-by: Harshul Rathod <harshulrathod1640@gmail.com>

* fix(native-chat): break the observed-send import cycle and keep renderer tests out of main

The observed-send path imported the clear helpers from native-chat-runtime-send,
which imports it back. Move the input-clear layer into its own module both use.
The Claude paste decoder test imported the renderer pending module from
src/main, which the node typecheck project cannot see; the echo-retirement
assertions now live in a renderer test.

* fix(native-chat): still submit a Claude chat send whose write acknowledgment was lost

A remote write whose acknowledgment is lost (timeout, dropped link) is not a
refusal, but the observed path stopped there and never sent Enter, leaving a
body that did land sitting unsubmitted in Claude's input line until the next
send's clear wiped it. Continue to the next write without re-sending the bytes,
as the unobserved path always did.

* perf(native-chat): keep terminal Chat pending delivery from re-rendering every row

The delivery notices were merged into a new Map on every render, which
invalidated the transcript row context and re-rendered every memoized row on
each stream update. The pending hook also wrote a fresh array on every status
ping and prune pass even when nothing changed, and the phone mapped its pending
list on every render, rebuilding the chat list data. Memoize the merged
notices, skip no-op pending writes, and memoize the phone's rendered pending
list. The phone also skips a transcript read when no send is due.

* fix(native-chat): never flag a queued Claude send, and flag one an idle Claude never starts

Two gaps in when terminal Chat calls a Claude send "Delivery unconfirmed":

A prompt sent while Claude is mid-turn is queued, and Claude folds it into the
running turn as a queued-command record. The transcript reader drops those
records, so once the turn ended the prompt Claude did run read as unconfirmed,
inviting a duplicate resend. A send made while the agent is busy is now never
checked; it keeps the pending behaviour it had before.

A prompt sent to an idle Claude that never starts a turn (Claude exited to the
shell, or the paste went nowhere) left the status at the same idle fact
forever, so the check never ran and the bubble stayed pending. An idle agent
starts a turn on a delivered prompt at once, so a send whose idle status is
unchanged after the existing 20 s bound is now checked against a fresh
transcript read.

* fix(native-chat): add the delivery notice strings to the English catalog

The Dismiss action's translate key was missing from en.json, which fails the
localization catalog and extraction gates. The desktop "Message not sent" and
"Delivery unconfirmed" notices were hard-coded English; route them through
translate with the same wording.

* fix(mobile): sync the held-send refs after commit instead of during render

Moving the acknowledgment-loss hold into its own hook made its render-time ref
writes new lines, which the React Doctor changed-lines gate blocks. Held sends
report after commit, so syncing those refs in a layout effect keeps them
current where they are read.

* fix(native-chat): report only definite terminal Chat send outcomes

The delivery rule inferred "Delivery unconfirmed" from "the turn ended and the
transcript has no matching row". Claude records a prompt sent mid-turn only as a
queued-command attachment, which the transcript reader drops, so that rule
flagged prompts Claude had answered. It also never fired for an idle Claude that
lost the write, because no newer turn arrives.

Keep only facts the transport reports:
- a refused write reads "Message not sent", keeps its text, and can be dismissed;
- a lost write acknowledgment holds the echo for 20 s, the phone's existing
  rule, then reads "Delivery unconfirmed" unless its row has landed.
An ordinary send, including one Claude queues mid-turn, stays pending as before.

Remove the agent-status subscription, the status-epoch origin, the fresh
500-row transcript read, the confirmed state and the no-status clock. The phone
already implements this rule, so its changes revert to main; only a test for
old-host paste envelopes remains.

* fix(i18n): translate the terminal Chat delivery notices

Add the Dismiss, "Message not sent" and "Delivery unconfirmed" strings to the
es, fr, ja, ko and zh catalogs, reusing each catalog's existing Dismiss wording.

* fix(native-chat): let a resend replace its failed terminal Chat echo

A "Message not sent" or "Delivery unconfirmed" echo kept its transcript
occurrence, so resending the same text numbered the resend as the second
copy: the one landed row retired the failed echo and pinned the resend
below the reply forever. Appending a send now drops a failed echo with the
same content first.

* fix(native-chat): unwrap a Claude paste that quotes pasted_content tags

The envelope parser refused any body containing a pasted_content tag, so a
pasted prompt that itself quotes one (a transcript excerpt, or code that
handles these tags) kept its wrapper and its echo stayed pinned below the
reply. Claude's per-paste id exists to disambiguate exactly that; only a
same-id tag inside the body is now ambiguous. Wrappers without an id keep
the strict rule.

* test(native-chat): pin which terminal Chat sends observe write outcomes

Only a Claude chat send (text or images) reports a refused or unacknowledged
write to its pending echo; other agents and slash commands keep the
unobserved write path exactly as before.

* fix(native-chat): keep failed terminal Chat sends through Stop

Stop cleared every optimistic echo, including a "Message not sent" or
"Delivery unconfirmed" bubble whose send had already settled. Stop cannot
affect that send, and the bubble is the only place its text stays copyable,
so it now survives until the user dismisses or resends it. Also moves the
observed-send import below the file header comment.

---------

Co-authored-by: Harshul Rathod <harshulrathod1640@gmail.com>
2026-09-30 01:04:21 -07:00
5b93c6216a Fix Chat UI paste intake and pane routing (#23784)
* fix(chat): separate text paste from attachments and route by pane

Keep composer text independent of image checks and saving, and route pastes
caught underneath chat to the originating pane's mounted input. Preserve
native event data, selection replacement, undo, and target lifetime checks.

Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: lurunzi <lurunzi@gmail.com>

* fix(chat): keep focus and quiet text paste after routing it to chat

- A paste inserted into the composer now moves focus there, as the old
  menu-paste insert did; otherwise a paste routed from the hidden terminal
  left the next keystrokes going to that terminal.
- With a remote-server or not-ready workspace, pasted text no longer shows
  the "Local attachments are not available" refusal because the clipboard
  also held an image rendition (common for Office copies). The menu path
  probes for an image only when the text read is empty, so a paired browser
  does one permission-gated clipboard read for a text paste, not two.
- Latest-value refs update in a layout effect instead of during render.

* perf(clipboard): answer "is there an image?" from the format list

The chat composer asks the main process whether the clipboard holds an
image before explaining an image-only paste on a remote-server workspace.
That probe decoded the whole image (readImage().isEmpty()) on the main
thread just to return a boolean. Read clipboard.availableFormats() instead,
and share the MIME check with the paired-web probe.

* fix(chat): a chat cover owns focus, so input never reaches the hidden terminal

When a Chat UI tab opened over its terminal, the terminal's xterm kept
keyboard focus until the composer claimed it a frame later, and forever if
the composer never became ready (still starting, a question card, a phone
holding input). The previous commits rerouted paste from that hidden
terminal to the chat, but typing and Enter still went to the terminal, an
image-only or refused paste left focus there, and about twenty
terminal.focus() call sites could put it back.

Make "a covered terminal cannot hold focus" structural instead:
- The chat cover takes focus in the commit that mounts it and marks the
  covered xterm inert, so every terminal.focus() path is refused by the
  browser. Split siblings are untouched. When the chat goes away the xterm is
  un-inerted, and gets focus back only if focus was inside that chat.
- Terminal paste listeners skip anything inside a chat cover (previously only
  inside a mounted chat root). The reroute from terminal to chat is gone;
  terminal-only paste is back to main's code.
- A paste that finds no chat input (before the chat mounts, or an approval
  card with no text field) gets a visible refusal from the cover. A disabled
  composer shows the same notice inline instead of dropping the paste. New
  copy: "Can't paste — this chat isn't accepting input right now." (the old
  "Worktree not ready" toast was wrong for a chat that is still starting).
- The terminal context menu, which names its pane, keeps a small request
  event to that pane's chat, now without a clipboard payload and using the
  existing covered-pane check.
- Cmd/Ctrl+V or Shift+Insert on a non-input part of the chat focuses the
  composer (or question answer) first, so the paste lands there.
- The composer-scope check used to decide whether a text field inside the
  chat keeps its own paste matched the whole pane (the file-drop surface
  carries the same attribute). It now asks the composer whether the target
  is inside its input.

* fix(chat): don't paste a copied file's name next to the file

Copying a file in Finder or another file manager puts its name on the
clipboard as text/plain beside the file itself. Since text and images are
now pasted independently, pasting such a copy into a local or SSH chat
inserted the file name into the prompt as well as attaching the image.

On the paste-event path, text/plain that is exactly the names of the pasted
files (one per line) is the file's label, not prompt text, so it is dropped
when an image from that paste is being attached. Rich-text copies (text plus
an image rendition) still insert their text, and a copied non-image file,
which is not attached, still pastes its name as before.

* fix(chat): don't type a Finder file's name on Cmd+V either

On macOS, Cmd+V in the chat goes through the app-menu paste, which reads the
clipboard text and saves the clipboard image separately. A file copied in
Finder also puts its name on the clipboard as text, so the composer typed
the name next to the attachment. On main the menu path never read text once
an image saved.

The main process now reports the paths of the files a file manager copied
(macOS filenames plist or file URL, Explorer's FileNameW, a Linux uri-list).
Text that only labels those files waits for the image outcome: dropped when
an image is attached (or refused on a remote owner), typed when none came.
The same label rule now also accepts a path or file URL per line, which is
how Linux file managers label copied files on the paste-event path.

* fix(chat): pane focus aimed at a chat lands on the chat

Since the covered terminal became inert, focusing a pane that shows a chat
(keyboard pane navigation, focus-follows-mouse, split activation) was refused
and focus stayed on the pane the user left, so typing went to that visible
sibling terminal. The one place a pane's focus is requested now puts it on the
pane's chat cover, which hands it to the composer when the pane is revealed.
Focus already inside the chat is left alone.

* test(terminal): give fake panes the container pane focus now reads

Pane focus checks the pane's container for a chat cover, and these two
fixtures built panes with only a terminal, so four tests threw.

* refactor(native-chat): move composer paste handle and chat-root key routing into their own modules

Brings NativeChatComposer.tsx and NativeChatResolvedView.tsx back under the
400-line limit after merging main. No behavior change.

---------

Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: lurunzi <lurunzi@gmail.com>
2026-09-30 01:03:25 -07:00
Brennan Benson 7afa4ee3dc test(e2e): read the tab strip's dock samples through a typed window field (#24052)
#24010's spec read them with Reflect.get, which the low-evidence lint rejects, so every PR's static analysis now fails on main.
2026-09-30 00:45:33 -07:00
Brennan Benson ca7c14db08 fix(mobile): start + menu, quick command and diff-note agents through agent.launch (#22954)
* fix(mobile): start + menu, quick command and diff-note agents through agent.launch

The session screen's + menu, agent quick commands and diff notes' New agent
session now ask the host to start the agent with agent.launchReplay, so the
host picks chat or terminal from the desktop's default and delivers any
prompt. Hosts without the launch capabilities keep today's paths.

The phone's pending tab choice is one value (a tab, a terminal by handle, or a
launched surface) instead of two refs, and a launched chat is found by its
session id in the next snapshot rather than a predicted tab id. A launched
surface waits a bounded number of snapshots for its tab.

* test(mobile): add the + menu and diff-note launch scenarios to the recording corpus

* test(mobile): repin bridged-parity tallies for the four launch goldens; drop test casts

The corpus grows from 790 to 794 goldens; all four new ones replay identically.

* fix(mobile): show a refused agent launch as a toast beside open tabs

The inline create error renders only in an empty session, so a host refusal
(for example a disabled agent) from the + menu in a session with tabs showed
nothing. Always toast the failure: the caller's own copy when it gave one,
otherwise the host's reason.

* test(mobile): type the launch reply helper with the shared launch outcome types

* fix(mobile): record a launched agent's tab as this device's pick on the host

A launch carries no navigation, so the phone selected the new tab only
locally while the host kept this device on the tab it had before. Leaving
the session and coming back, or a reconnect that reset the screen, reopened
that old tab. The "+" terminal path this replaced asked the host to select
the tab for the caller.

When a launched surface's tab lands in a snapshot, activate it for the
caller exactly as a tap does. The resolver now names the landed tab in
place of the unused `missed` flag. Route parity re-pinned for the new
activation body, identity payload and strings.

* fix(mobile): land on a launched agent's tab without a 500 ms wait or a blank pane

The host publishes a launched tab before it replies, so the tab list the
phone already holds usually has it by the time the reply arrives. The
launch paths still waited for a refetch 500 ms later, leaving the phone on
the old tab for that long after every launch. Read the tab list at once.

On hosts without agent.launch, the chat path also unsubscribed the open
terminal and cleared its handle before the chat's tab landed, while the old
terminal tab stayed selected: a blank pane until the next tab list. Leave the
open tab live until the chat lands, as the launch path does; applying that
tab list tears the old terminal down.

Route parity re-pinned for the two bodies.

* fix(mobile): keep a tab the user picked while a prompted launch was still replying

A quick command or review-notes launch now waits for the host to deliver the prompt, which can take up to a minute. The launched tab shows up in the tab row well before that, so a user who tapped another tab meanwhile was pulled back onto the launched one when the reply arrived, and that pick was recorded on the host.

The launch now remembers which tab the phone was on when it started and only takes focus if the phone is still there when the reply lands. Any move made in between, by a tap or by the computer navigating this phone, wins. Session route parity re-pinned for the handleCreateTerminal body only.

* fix(mobile): name a launched agent's tab before asking, and land on it when it is listed

A "+" menu, quick-command or review-notes launch now reserves its tab before it asks the host: a fresh pane key (tab and leaf UUIDs) and, for an agent the host may start as a chat, a session id. Both are minted once per launch and sent unchanged on every replay, since the host's replay fingerprint covers them.

The phone arms its pending selection with that reservation before sending, so it lands on the terminal (matched by pane halves) or chat (matched by session id) as soon as the tab is listed. For an agent whose prompt is pasted after start, that is long before the reply, which waits for delivery. Landing also frees the "+" lock; the lock holds the create's id, so an older launch's reply cannot free a newer one's. The reply now only adds its own handle or session id (an older host ignores the reservation), starts the fallback countdown, and reports prompt delivery.

A tab the user picks mid-launch replaces the pending selection, so the launch-start tab check is gone. A reservation the host refuses as already taken reads "Couldn't start the agent. Try again." on the first send, and as unconfirmed after a replay. The mobile UUID fallback now yields a v4 UUID, because a pane key's leaf must be one. The host launch path moved to new-tab-agent-host-launch.ts; session route parity re-pinned for that move and the landing's lock release.

* test(mobile): expect the launch reservation in the four launch scenarios

The four launch scenarios now expect the pane key and session id the phone sends (the scripted ids come first, so the operation id moves from ...001 to ...004).

* fix(mobile): don't say an agent may not have started while the user is looking at it

When a launch's reply was lost after its tab had already landed, the phone said "Couldn't confirm the agent started", although the listed tab proves it did. Now a listed tab narrows the doubt to the prompt or notes ("The agent started, but couldn't confirm the notes were sent."), the notes stay unsent, and a bare launch says nothing. Only the nested-function parity pin moves, for handleCreateTerminal passing the tab list to the launch.

* test(mobile): read the launch's sent reservation through the host's params schema

The anti-slop audit rejects Reflect.get; parsing with AgentLaunchReplay also
asserts the host accepts the params the phone sent.

* test(mobile): check the launch reservation against the host without importing its schema

Mobile code may import the params contract only as types. The phone's tests
now read the sent reservation by narrowing, a chat reservation is checked
through the real host dispatcher, and the older-host drop is pinned host-side.

* fix(mobile): don't send the same review notes to a second new agent

The "+" lock is now freed when the launched tab lands, but review notes are
only cleared when the launch's reply confirms delivery, which for a prompted
launch can take up to a minute. In that window "Send review notes to AI" still
offered the same notes, and choosing a new agent session started a second
agent with them.

The notes a new agent session is being started with are now held from the tap
until that launch settles: the Send button no longer counts them, the sheet no
longer offers them, and a stale tap on the old sheet starts nothing. Notes the
host did not deliver become sendable again once the reply arrives.

* test(mobile): record the + menu and diff-note launch goldens

4 added (+ as a terminal, + as a chat, notes delivered, notes not delivered). 10 existing create-terminal goldens move only because the recorded state now shows one pending selection instead of two refs; their requests are unchanged.
2026-09-30 00:42:52 -07:00
Brennan Benson fceca5cece fix(sidebar): an agent's row stays while it runs, whatever its tab title (#23948)
* fix(sidebar): keep hook-less agent rows while the agent runs, whatever its title

Codex retitles its pane to the project name, so the sidebar's title-derived
row (which required the title to name an agent) vanished while Codex kept
running (#23767). Rows now take identity from the canonical pane resolver
over the pane's foreground-process read and launch record, then the title;
the title only decides idle/working/needs-input. The row still goes away when
the PTY exits, the process tracker proves the shell is back, or the title is a
shell or default title.

* test(dashboard): justify the partial store fixture's type assertion

* fix(sidebar): only a live process read keeps a plain-title agent row

Review of the previous commit found ghost rows: the tab launch record is a
latch nothing clears on WSL, after an SSH exit, or for a launch that never
started, and a parked pane's process read went stale because only the
mounted tracker re-derives it.

- The launch record returns to main's role: a fallback only for titles that
  show activity, ranked below a title naming another agent (pane reuse),
  matching the tab icon's order.
- A parked pane's command boundary retires its unconfirmable process read,
  like the mounted ladder's unavailable path; reveal re-reads it.

* fix(sidebar): confirm before a parked marker retires an agent; read Git Bash prompt titles as the shell

- A parked pane's end-of-command marker can be a nested shell's leak under a
  still-running full-screen agent, so confirm the foreground first (as the
  mounted ladder does) and retire the process read only on a shell or no
  answer. SSH/remote parked panes hold no incarnation to fence a host read
  with, so they still retire.
- Git Bash emits no command marks; its `$MSYSTEM:$PWD` prompt title
  (MINGW64:/c/repo) is now shell evidence, so a stale Codex read there no
  longer keeps a ghost row after Codex exits.

* fix(sidebar): trust only process-read agents for plain-title rows; per-worktree foreground selector groups by tab

A daemon reattach seeds the pane's foreground entry with its launch agent, which
can outlive the process while Orca is closed. The entry now records where its
agent came from (agentEvidence), and the sidebar/dashboard title-derived rows
only keep a plain-title row on an actual process read. Routing and the tab icon
are unchanged.

selectPaneForegroundAgentsForWorktree grouped every pane key per worktree; it now
groups by tab once per map identity and skips worktrees with no tabs.

* fix(sidebar): a parked pane's reattach keeps its own process read of the same agent

The reattach seed marked a returning parked Codex pane as launch-record
evidence, over the process read this session already took, so its row
blinked out on reveal and stayed hidden if the user left the tab before
the visible read landed. Keep the read when it names the same agent; the
seed still drops byte-routing trust.

* test(terminal): foreground confirmation publishes process-read evidence

* fix(sidebar): a cleared pane title retires the agent's process read

Codex clears its title when it exits, and the tab then shows its default
title. A pane without shell command marks never re-reads its foreground
process, so the retained read kept a "Codex · Idle" row after /quit
(permanently for a hand-typed Codex; about 15 s while the marked-pane
confirm ladder ran). Treat a blank title like the default title it shows.

* fix(sidebar): the pane's process monitor retires an exited agent's process read

A hook-less pane keeps its sidebar row from the tracker's foreground-process
read, but nothing re-derived that read in a pane without OSC 133 command
marks. After Codex exited there, a "Codex · Idle" row stayed: permanently
when the shell titles its prompt, or when a killed Codex leaves its last
title.

The pane's agent-completion process monitor already confirms an agent's
exit (no agent and no child processes, held past its settle window). It now
reports that exit to the tracker, which retires its own process read and
runs the confirmed-shell path the visible-pty read uses. A tracker read that
names an agent seeds the monitor, so hidden panes and panes the monitor had
not polled yet are watched too. A command read in flight still decides the
pane, and launch records or other agents' reads are left alone.

* fix(sidebar): a monitor-confirmed exit leaves the next agent in an unmarked pane identifiable

The process-exit retire published shellForeground:true and left the one-shot
visible sample settled; a pane without command marks has no command start to
lift either, so a Codex typed again after quitting was never read and lost its
row on retitle. Publish shellForeground:false and reopen the sample.

* test(terminal): justify the pane binding cast in the process-exit relaunch test
2026-09-30 00:17:53 -07:00
Jinjing f33f3093cb docs(wechat): point community QR code at group 11 (#24050)
Group 10 is full; swap the README QR code and copy (all locales) to the new group 11 invite.
2026-09-30 00:11:49 -07:00
Brennan Benson 444e0b1cf9 fix(codex): recognise Codex's quoted spellings in config.toml, and repair Orca's duplicates (#22592) (#23958)
* fix(codex): recognise Codex's quoted project-trust spellings in config.toml (#22592)

Codex's settings screen writes project trust as ["projects"."/p"] and
"trust_level" = "trusted". Orca's matchers only knew the bare spelling, so a
trust write appended a second [projects."/p"] table (or a second trust_level
line) and every codex command then failed with "duplicate key". The config
mirror kept both spellings in Orca-managed homes for the same reason.

- Project table headers are now read through the existing TOML key-path
  parser, so bare, quoted, literal-quoted, mixed and spaced spellings are the
  same table for trust writes and the managed-home mirror/dedupe.
- trust_level is found by decoded key, in both the trust writer and the
  mirror's trust reader, and an existing key is rewritten, never duplicated.
- On the next trust write, a table older Orca appended (exactly
  [projects."<p>"] holding only trust_level = "trusted") that duplicates the
  user's table, or the bare line it inserted under a quoted "trust_level", is
  removed; the user's table wins and the atomic writer keeps config.toml.bak.
  Any other duplicate, or a repair that would still leave one, leaves the file
  untouched and logs once.

* build(cli): list the new Codex trust modules in the CLI project

* fix(codex): recognise Codex's quoted hooks.state spellings and repair Orca's copies (#22592)

Codex writes hook trust as ["hooks"."state"."<key>"] (and the parent as
["hooks"."state"]). Orca's hook-trust writer, parent-table check and mirror
only knew the bare spelling, so a hook-trust write appended a bare copy and
the file failed to parse with "Cannot declare ... twice".

- The hooks.state header, parent-table and mirror checks now use the TOML
  key-path parser, like project tables.
- The duplicate repair now also removes Orca's own hooks.state tables (an
  exact [hooks.state."<k>"] with only enabled + trusted_hash, or an empty
  [hooks.state]) that repeat a table in another spelling, and runs on hook
  trust writes too, so a file with both project and hook duplicates is fully
  repaired. The Orca-shaped copy is removed whichever order the two tables are
  in, only when exactly one other table (the user's) remains; anything else is
  left untouched and logged once.

* fix(codex): carry plain-Codex plugin and project hook trust into Orca's Codex homes (#22592)

Codex keeps hook trust in $CODEX_HOME/config.toml under hooks.state, keyed
by the hook's source. Plugin keys (`id@mkt:path`) and project keys
(`<repo>/.codex/...`) are the same in every home, but the mirror dropped
every hooks.state table from ~/.codex, so Codex inside Orca asked users to
re-trust plugin and project hooks they had already trusted in plain Codex.

- classifyHookTrustKey splits keys into home-scoped (the home's own
  hooks.json/config.toml, re-keyed by install as before) and shared.
- The mirror now carries shared hook trust from ~/.codex in every spelling.
  A key the managed home already holds keeps the managed copy, a key
  repeated in ~/.codex is carried once, and the parent [hooks.state] table
  is never copied, so the result never declares a table twice.
- mergeSystemCodexConfigIntoRuntime moves to codex-config-mirror-merge.ts
  to keep codex-config-mirror.ts under the line limit.
- Tests cover plugin/project carry in each spelling, user-hook keys staying
  out, repeated launches, managed-copy precedence, Windows key spellings,
  parent tables, and user-hook trust re-keying (trusted_hash and enabled)
  from every ~/.codex spelling.

* fix(codex): carry session_end and interrupt hook trust into Orca's Codex homes (#22592)

The shared-trust classifier parsed hook keys with Orca's own trust-key
parser, which only knows the ten events Orca installs hooks for. Keys for
Codex's session_end and interrupt events did not parse, so their plugin and
project trust was treated as home-scoped and left out of the managed home.

The classifier now reads the source path from Codex's key shape
`{source}:{event}:{group}:{handler}` for any event label. A key without
that shape is still never carried. Tests cover both events for plugin and
project keys in both spellings, user-layer keys for both events, and five
unattributable key shapes.
2026-09-30 00:09:26 -07:00