Commit Graph
1168 Commits
Author SHA1 Message Date
Jinwoo Hong 98fdbc4ade fix(orchestration): file federated worker mail under the coordinator Run (#19542) 2026-09-08 04:03:07 -04:00
Neil da836faeef fix: preserve terminal retirement proof across renderer publications (#19002)
* fix: preserve terminal retirement proof across renderer publications

* refactor: share the live-surface filter between retirement proof preservation and projection

The publication projection already dropped proofs whose surface is live;
reuse that as one helper instead of a second inline scan.

* fix: emit stored retirement proofs from host-authored snapshot writes

Three callers built a snapshot, stored it, then emitted the pre-store object. Storing grafts on the preserved proofs, so those frames carried the stored snapshotVersion without the proofs; subscribers dedupe on version and never saw them.

* fix: send terminal retirement proofs once per stream and fence them by occupant

Proofs are pinned per worktree for the host's lifetime, so every snapshot publication — including a 50ms title tick — re-shipped up to 64 proofs (~17 KB on realistic ids) to every paired client.

Negotiate session-tabs.retirement-proof-delta.v1: the host projects each session-tabs stream to send a proof only the first time that stream carries it, and a capable renderer keeps the union in a ledger keyed by (environment, worktree) with the same 64-entry bound and the same live-surface drop rule as the host, reset on removed frames and on a new connection generation. Legacy clients keep receiving the full list; CLI and mobile do not advertise the capability.

Also inherit worktreeInstanceId onto identity-less host writes so a host write between two renderer occupants can no longer launder one occupant's proofs into the next.

* fix: keep an empty proof delta distinguishable from a proof-less host

A negotiated stream now sends retiredTerminalSurfaces: [] when nothing is new instead of omitting the field. Absence is the host's "I hold no proofs" signal — which is also what a recreated worktree's fresh host entry publishes — so the client ledger forgets on absence and a successor occupant never inherits its predecessor's proofs, even when the removed frame was missed.

* test: pin ledger visibility against a legacy full-list host

An old host sends the full proof list whenever it holds any and omits the field when it holds none. Prove the new client ledger shows exactly what a legacy client would see across that sequence, so forgetting on absence is verified not to regress the mixed-version case.
2026-09-07 19:28:17 -07:00
Brennan BensonandMerge Sim 3b8128df04 fix(orchestration): index per-PTY mailbox reservation cleanup (#19390)
Co-authored-by: Merge Sim <sim@local>
2026-09-07 17:42:23 -07:00
Jinwoo Hong bcd4076dd1 fix(relay): never cache a region hint from a one-region catalog (#19349)
* fix(relay): never cache a region hint from a one-region catalog

The director lists only regions with a serving cell, so a roll wave shortens the
catalog to one entry. The resolver required only every *listed* region to be
measured, so that lone region won against nothing and was cached for 24 h: a US
desktop refreshing while US cells rolled published asia-east2 for a day, the
incident #19233 was written to end. Now fewer listed regions than the fleet serves
withholds the hint (1 h no-hint TTL), the same outcome as an unmeasurable peer.

* test(relay): give the unstable-probe case a two-region catalog so it has one cause
2026-09-07 16:44:33 -04:00
Brennan BensonandMerge Sim 5cefb440bf fix(native-chat): stop an unanswered host from reading as one that refuses structured chat (#19321)
* fix(native-chat): stop an unanswered host from reading as one that refuses structured chat

`readLocalRuntimeCapabilities()` returned `[]` both before the first status probe
landed and after one failed, so "not asked yet" and "host says no" were the same
value. Every structured-chat launch route consumed it, and an unprobed host was
routed to legacy chat exactly as a refusing one is.

Keep the two apart: the cache holds `null` until a probe succeeds, a failed probe
leaves it `null` rather than emptying it, and the launch route names the case with
its own blocker instead of borrowing `runtime-capability`.

No routing outcome changes — both cases still decline structured chat. The point is
that the reason is now truthful, which is what the routing work needs to build on:
once a launch can target a runtime peer, capabilities come from that host, and an
unanswered remote must not be indistinguishable from one that refuses.

`hostCapabilities` on the launch route stays local-only at every call site; a
per-target resolver replaces it when the route learns to reach a peer.

* test: cover unknown runtime capability lifecycle and launch fallback

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-07 13:25:40 -07:00
Brennan BensonandMerge Sim ce4a3a4186 feat(chat): add structured session rewind backend (#19235)
* feat(chat): add structured session rewind backend

* fix(chat): make interrupted session rewinds recover safely

* fix(native-chat): negotiate rewind runtime capability

* fix(native-chat): consolidate remaining adapter imports

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-07 12:20:24 -07:00
Jinjing 9fed61e5c2 Persist agents sidebar search visibility as pairing-local preference (#19313)
* Persist agents sidebar search field visibility as pairing-local preferen

- Add `agentsShowSearch` to workspace UI state with default on
- Include in pairing-local fields so preference syncs across clients
- Convert search from menu action to checkbox menu item for explicit toggle
- Update activity thread options menu to reflect checkbox state
- Add localization strings across all supported languages
- Update RPC schemas and preference persistence layer
- Includes readiness validation reports confirming feature is clean

* rm review

* fix documentation
2026-09-07 11:27:40 -07:00
Jinwoo Hong 5857357fcf feat(relay): log the region probe and name the assigned cell (#19307)
* feat(relay): log the region probe and name the assigned cell

A desktop silently pinned itself to a far relay region for a day and every
phone connect paid the round trip. Nothing in the desktop logs said which
regions were probed, what they measured, why one was rejected, or which cell
the host landed on, so the only way to diagnose it was a bench harness.

The resolver now emits one line per outcome. A refresh carries every region's
probe origins, the discarded warm-up, the kept samples, the minimum, the
spread, and a verdict, then the chosen region or no-hint with the reason it
withheld one. Cache hits, diagnostic overrides, and a director that cannot
list its regions each get their own line so a quiet run is never ambiguous.
Self-heal logs the cached region, the best measured region, the assigned
cell's round trip, and whether it kept or deleted the cache. Only a refresh
reports a catalog failure; a self-heal never chose a region, so a line saying
it withheld a hint would be a lie.

Relay status now carries the assigned cell so the pairing panel can name it.
The field is optional because an offline host holds no assignment and the web
client answers from a stub that never has one.

Splitting catalog fetching out of the preference module keeps both files
inside the line budget without a lint disable.

* fix(relay): drop the assigned cell from statuses not served on it

The origin pool publishes offline while it still holds the assignment it is
about to rotate, so the panel kept naming a cell nothing was served from. The
same class of bug hid a second instance: the coordinator republishes
registered right after the broker announces its cell, and that republish
carried no cell, blanking the value moments after it was set. The cell would
never have reached the panel in the real flow.

Deriving the cell from the status at each publisher removes both. The rule
lives beside the status type because it defines when the optional field is
populated, and the coordinator reads the owned broker's endpoint rather than
trusting a call site to remember to pass it.

* i18n: add the relay cell label to the English catalog

* test(relay): audit the relocated region catalog fetch call site

* fix(relay): report a self-heal whose catalog request failed instead of staying silent
2026-09-07 13:45:48 -04:00
Jinwoo Hong e60d9aaac9 fix(relay): attach a phone whose accept straddles a control rebind (desktop side) (#19238)
* fix(relay): attach a phone whose accept straddles a control rebind (desktop side)

The cell announces a connection with a single conn-open. When the desktop's
control socket dies mid-accept the phone waited out the 10s attach deadline and
was closed HOST_OFFLINE, even though the desktop was online. host-hello-ack
already restates those connections in pendingConns; the desktop parsed the field
and threw it away.

Desktop replays pendingConns on activation. It needs kind and relayDeviceId to
dial: they decide the pairing authority a connection carries and the E2EE device
binding, so neither may be guessed. Two sources, in order:

- the ack entry, when the cell states them. This is the case where the desktop
  never received the conn-open at all, i.e. the headline scenario, and it needs
  the cell change in #19266.
- the conn-open this process already saw, when the frame arrived but the data
  socket died with the control. Works against every deployed cell.

An entry described by neither is skipped with a warning rather than dialed.
Entries already spliced or already holding a data socket are skipped, so a
rebind never double-dials.

The desktop advertises x-orca-host-capabilities: pending-conn-details on the
control upgrade so a cell knows the ack entries will be read. The header name
and token are duplicated by hand because the desktop cannot import the relay
contract; both sides assert the literals so drift fails a test rather than
silently disabling the feature.

Also stop dropping a conn-open that lands while the control is draining. A
drain-only cell refuses new phones, so such a frame predates the drain and only
that cell holds the waiting phone.

* test(relay): pin the attach deadline the desktop mirrors by hand

RELAY_HOST_ATTACH_DEADLINE_MS duplicates RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs,
which the contract suite already pins to 10_000. Now that the cell and desktop
halves land as separate PRs the two can drift independently, and drift would
silently shorten both the observed-open eviction window and the deadline a
replayed dial states, with nothing failing.
2026-09-07 13:28:44 -04:00
Jinwoo Hong 33af0af4ea fix(relay): stop rejecting the near region on a cold first probe (#19233)
* fix(relay): stop rejecting the near region on a cold first probe

The region probe counted the process's first /health request, which pays TCP
and TLS setup, as a latency sample. The resulting spread rejected the near
region on essentially every cold run, leaving the far region as the sole
survivor and pinning US desktops to Asia cells for 24 hours.

Discard a warm-up probe per origin, compare regions by minimum latency, and
keep the spread check only for a genuinely flapping path. A region now wins
only against a measured competitor; a rejected or unmeasurable peer sends no
hint, which is remembered for an hour so a reconnect does not re-probe. An
origin that fails its warm-up is dropped before the sampling rounds, so an
unreachable region costs one probe timeout instead of four.

After a control socket registers, probe the cell we landed on once per
process, and delete the cache only when it names a region other than the best
measured one and that cell is more than 3x slower -- a far cell under a
correct cache is the director declining the hint, and re-measuring would
return the same answer.

* test(relay): audit the region probe's global fetch call site
2026-09-07 13:11:04 -04:00
Brennan BensonandMerge Sim 0252fe5c36 feat(native-chat): show Codex subagent activity instead of opcode rows (#18773)
* feat(native-chat): show Codex subagent activity instead of opcode rows

Codex spawns subagents and reports their lifecycle, but Orca rendered only
gray `codex · item:subAgentActivity` opcode rows. Build the real display: one
summary row per spawn group with a live working count and token usage.

State is accumulated from `subAgentActivity.kind` alone. A live probe against
app-server 0.152.1 showed `agentsStates` arrives empty even in a real subagent
run, and that every activity item is delivered twice (item/started and
item/completed), so every transition is idempotent and terminal states latch.

Children never receive `thread/started`, so there is no nickname, role, or
depth to read; the row labels from the trailing segment of `agentPath`.

Two sweeps keep a row from claiming work forever: the parent turn's terminal
event settles still-running children, and session start marks a pre-restart
roster unverifiable rather than exited, since Codex resume replays no
non-message items and no event can ever settle them.

The roster rides a new NativeChatBlock variant paired with a plain-text twin.
A journal item kind could not be used: that union is closed, and an unknown
kind parses as malformed, which is the corrupt-journal class that can hide the
chat tab. Block types are explicitly admissible when unknown, so an older
client drops the block and renders the sentence.

MessageRow moves out of NativeChatMessageList to keep both files under the
max-lines budget without a disable.

* feat(native-chat): give the subagent summary row its bot glyph

The row led with a glyph that swapped on state — a check once every child
completed, a group icon otherwise — so a group appeared to change identity
the moment it settled. Per the approved mock, the glyph names the category
and never moves: state is carried by the status dot and the tone of the
words beside it.

Use lucide `bot`, the same glyph the individual `subAgentActivity` rows take
in the eight-category vocabulary, so the summary reads as their parent. Slot
and glyph are the mock's 16px/14px, muted by default, and the svg is
`aria-hidden` — the headline is what a screen reader announces, so the icon
never stands alone.

* fix(native-chat): correct the Codex subagent roster's build, journal write, and failure reporting

* Restore the exhaustive block handling that adding `subagent-group` to
  `NativeChatBlock` broke. `formatWorkerTranscriptMessage` and `boundBlock`
  both fell through to `image-ref` field access, so `tsc -p` failed for the
  CLI and node projects and `build:cli` could not emit. Both now guard on
  `image-ref` explicitly and give the roster block its own branch.

* Stop the roster's publish from evicting its own append. The sink queue
  coalesces by `coalescingKey` alone with no op-kind check, so passing the
  append's key to `tryPublish` spliced the queued append out and the row
  never reached the journal — permanently, since `lastSerialized` was
  already set. `tryPublish()` now takes no argument, matching every other
  call site. The regression test's fake sink honours the key, which the
  previous fake did not.

* Keep `collabAgentToolCall` substantive. Only the MultiAgentV2 path emits
  `subAgentActivity`, so a V1 turn has no roster row; suppressing its collab
  tool calls too would have left a V1 fan-out showing nothing at all.

* Surface a settled failure while siblings still work. The summary now
  reports the worst adverse outcome independently of the group verdict, so
  the row shows `3 working +1 failed` with a failed-coloured dot instead of
  a neutral pulsing dot. The plain-text twin names it too.

* Treat `/morpheus` as a child. Only `/root` is the turn itself; the old
  segment-count test silently dropped a valid single-segment agent.

* Refresh token-usage recency on update so an active thread is not evicted
  as the oldest entry, and scope the `agentsStates` comment to the V2 path.

* fix(native-chat): stop the subagent roster announcing a new duration every second

The roster row is an `aria-live="polite"` region and it contains the elapsed
clock, which reticks once a second for as long as the fan-out runs. A screen
reader therefore reads out a fresh duration every second, burying the state
changes the live region exists to report — the headline, the verdict, and the
`+1 failed` alert.

No other live region in the transcript does this. `NativeChatToolRun`'s live
button holds only the active tool label, and in `NativeChatWorkingStatus` the
variant that shows a duration is precisely the one with no `aria-live`.

Hide the clock from the accessibility tree only while it is moving. Once the
group settles the duration is fixed, so it stays readable and costs no
announcements.

* fix(native-chat): retry a refused roster publish, and stop two wrong readings

Four defects from a third review pass over the Codex subagent roster.

`write()` set `lastSerialized` before the append and rolled it back only when
the APPEND was refused. A refused PUBLISH left it set, so an identical replay
short-circuited and the revision was never published again. The repo's own
pattern is the opposite: `codex-structured-item-streams.ts` advances
`checkpointLengths` only once the append AND the publish are both accepted.
Roll back on either half.

That alone did not cover the sweep, which is the LAST event a group ever gets:
its `changed` guard skips the write on a retry because every child has already
latched, stranding the settled roster's final revision. Write when the previous
attempt was refused part-way, too.

`formatWorkerTranscriptMessage` read `block.agents` as its exhaustive fallback.
The journal schema deliberately admits block types this build does not know and
`client.call` casts the RPC result instead of validating it, so a newer remote
host's block reached that line and threw `agents is not iterable`, taking down
the whole `worker read`. It printed a harmless `[image omitted]` before. Match
`subagent-group` explicitly and degrade the unknown case.

The elapsed clock measured to `now` whenever no child carried a terminal
timestamp. That is exactly the roster restored from the journal after the host
died: the reconciler latches `unverifiable` without a `settledAt`, so a child
that ran four seconds reported the time since the crash as its run length, on a
row that is not even counting. Show no duration when none is known.

Also restores package.json to origin/main: the merge had deleted one of main's
two duplicate `bench:terminal-partial-escape-tail` keys. Behaviour-preserving
(JSON is last-wins and the deleted line was the dead one), but unrelated to this
PR and better left to its own change. No gate rejects duplicate JSON keys.

The new refusal tests also cover the append-side rollback, which had none.

* fix(native-chat): stop the subagent roster vanishing from every settled turn

`NativeChatToolRun` bailed out for a completed turn whose activity disclosure
is collapsed before it reached the branch that draws a roster-only run. That
guard exists to push TOOL activity behind the turn-status disclosure, and it
fires on exactly the shape a spawn group has: a roster message carries no tool
blocks, so `selectActiveToolCall` returns null and `isSettled` is true, while
the list passes `expandOverride={expandedTurnIds.has(turnKey)}` — false until
the reader opens that turn — and `activeTurnIsWorking={false}`.

That is the default state of every finished turn in the transcript, so the one
compact row this feature exists to leave behind ("Ran 3 subagents") disappeared
the moment its turn ended. Worse, `MessageRow` counts a spawn group as
renderable specifically so the row survives, then rendered a wrapper around a
component that returned null — the empty ghost bubble its own guard is written
to prevent.

Order the roster branch before the disclosure guard. A roster has no tool
activity to hide, and the guard's reasoning ("a failed child command looked
like the whole response was still running") does not reach it. Runs that do
carry tool blocks still fall through to the guard unchanged, and in practice a
roster never shares a message with them: it is its own `role: 'system'` journal
row and `isToolOnlyMessage` is false for it, so `foldToolMessages` never merges
tool blocks into it.

Also drop childless groups when building the rows, so `subagentRows.length`
stays an honest test of "something will draw" — the roster-only branch returns
a margin-bearing wrapper on the strength of it, and a group with no children
renders null.

Both tests fail with their fix reverted; the existing NativeChatToolRun suite
still passes, so the completed-turn disclosure behaviour is unchanged.

* test(native-chat): cover the subagent roster at the message-list level

Every defect this feature has shipped so far lived in the assembly between
rows, and the row-level suites kept passing through all of them. Loop 4's
regression — a settled roster swallowed by the completed-turn disclosure —
was found by reading the code, not by a test, and an independent visual-proof
run observed the same symptom in the real UI and routed around it rather than
reporting it. `NativeChatToolRun` rendered alone is handed `expandOverride`
and `activeTurnIsWorking` by the test author, so it agrees with whatever the
caller was assumed to pass.

Drive the real component instead. The roster is its own `role: 'system'`
journal row carrying the producer's two blocks (structured + plain-text twin),
so what reaches the DOM depends on `foldToolMessages`, the turn-key mapping
and the disclosure state `NativeChatMessageList` owns — none of which a row
test exercises.

Three cases, on one assembled transcript that holds tool calls AND a roster:
  - a settled turn with activity collapsed, the resting state of the whole
    transcript, still shows the row (fails with loop 4's reorder reverted);
  - tool activity stays behind that disclosure and appears only on expand,
    and expanding draws no second roster (fails with the guard removed);
  - a working turn reads as a live spawn.

The first also pins that the plain-text twin is dropped rather than printed
beside the row it stands in for.

Timestamps are explicit and ascending: the list re-sorts by (timestamp, id),
so rows sharing a millisecond tie-break alphabetically and the user turn can
sort last, stranding the roster outside its own turn and reconciling live
children to `unverifiable`.

No production code changed.

* fix(native-chat): make "counts as renderable" and "actually draws" agree for a spawn group

`MessageRow` counts any `subagent-group` block as renderable, but
`NativeChatSubagentRun` renders null for a childless roster. A group with
`agents: []` therefore mounted a row that drew nothing — an empty div that still
costs the transcript one `gap-5` slot. The Codex producer never writes one (every
`write()` call site operates on a group that already holds an entry), but the
block schema admits `agents: []` with no `.min(1)`, and the wire is where such a
shape would arrive.

Narrow `subagentGroupBlocks` — whose only production caller IS that renderable
check — to the groups that will draw, behind a named `isRenderableSubagentGroup`
that `NativeChatToolRun` now shares in place of its own copy of the predicate, so
the two guards cannot drift apart again. A childless group carrying its
plain-text twin now prints the twin, which is what the twin is for; a bare one
skips the row entirely.

Also correct four comments that had stopped describing the code:
  - the roster header called `agentsStates` "always empty", contradicting the
    probe note in `codex-subagent-activity.ts` — it is empty on the MultiAgentV2
    path that emits these items, and the V1 path does populate it;
  - `tokensByThread` was documented "retained UNCONDITIONALLY" while
    `handleTokenUsage` LRU-caps it 65 lines below;
  - the sweep is not "the LAST event a group ever gets": neither `settleTurn` nor
    `settleSession` removes the group, so a later `thread/tokenUsage/updated`
    naming a swept child still writes it. The retry condition is right; only its
    stated reason was wrong;
  - the `subAgentActivity` classification is not reached "for every event — and
    every one of them arrives twice". `handleSubagentItem` intercepts those items
    before `items.handle`, so the live path never consults the catalog;
    `restoreThread` replays them straight through, and is the real consumer.

Comment-only apart from the childless-group guard.

* fix(cli): stop `worker read` printing the subagent roster sentence twice

The producer ALWAYS writes a roster block beside a plain-text twin carrying the
same sentence, for clients that cannot draw the block. The renderer honours that
contract from one side — it draws the block and drops the twin. The CLI honoured
neither side: it printed the twin as prose AND rendered the block as
`[subagents] <same sentence>`, so a real roster message read

    [system] Ran 2 subagents (1 failed)
    [subagents] Ran 2 subagents (1 failed)

Take the mirror of the renderer's rule, which is the cleaner half for a text
client: the twin IS the sentence, so print it and drop the block it stands in
for. A block that arrives WITHOUT its twin — a shape the wire admits and no
producer writes — still stands in for itself, because dropping it
unconditionally would lose the roster entirely. Either way the sentence prints
exactly once, off the same `subagentGroupFallbackText` helper both sides use.

Unreachable through `readWorkerTranscript` today, whose provider rollout decoder
never emits a `subagent-group` block — but the formatter is the CLI's contract
for any transcript source, and the shape is already producible.

The test pinned a TWIN-LESS group, a body `codexSubagentGroupBody` never writes:
it asserted the exact double-print this fixes was correct output, and would have
blessed either behaviour. Rebuild the fixture as the producer's real two-block
row, with the sentence taken from the shared helper rather than hardcoded so it
cannot drift, and assert the sentence appears exactly once. The twin-less shape
keeps a test of its own, labelled as the wire-only fallback it is.

Also record why `settleTurn` keys on the RAW `turnId` while `groupFor` remaps
off-primary activity onto the primary's active turn. The asymmetry is
load-bearing, not an oversight: were `settleTurn` to remap, a child thread
ending its own turn would sweep the parent group and settle every still-working
sibling to `unverifiable`. The lookup missing is the intended no-op.

* fix(native-chat): add the subagent roster's localization keys and narrow its twin filters

The roster row called 16 `components.native-chat.subagents.*` keys that were
never added to the catalog, failing the localization gate. Synced en.json; the
English strings are the component's own inline fallbacks, so nothing renders
differently.

Also tightens the twin/block handoff on both readers. The renderer dropped
every text block once a roster was present, which is safe only because Codex
writes a roster as its own message — the block is provider-agnostic, so a lane
folding prose in beside one would have lost it on desktop while mobile kept it.
And both readers decided "the twin is already printing" by recomputing the
sentence and comparing bytes, which a roster from a newer build never matches:
its unknown state normalizes to `unverifiable` here, so the CLI printed the
roster twice with two different verdicts. Both now recognize a twin by shape.

* test(native-chat): pin the roster twin recognizer against prose

Both readers use it to decide the twin is already printing, so a false positive
eats a message's real prose and a false negative prints the roster twice.

* docs(codex): restore the roster's evictionated trigger to its KNOWN LIMITATION

The previous rewrite dropped both triggers the old comment named and kept only
the restart one, but eviction is the reachable half: `groupFor` caps `groups` at
MAX_CODEX_SUBAGENT_GROUPS and drops the oldest-INSERTED entry (it returns an
existing group without re-inserting, so this is not LRU), which can evict a
still-live group in-process. The row identity is keyed on the group id alone, so
the next activity item rebuilds that row from one child — the same N-to-1
rewrite, with no restart, and with the sweep skipped so the children never latch
`unverifiable`. Also softens "every real turn id is freshly minted" to the
provider assumption it is: turn ids are read verbatim off provider frames and
nothing in this repo mints or asserts them.

* docs(codex): justify the subagent wire notes from the live probe alone

The roster and disposition comments explained themselves in terms of a
provider-internal path taxonomy rather than anything this repo can observe.
Restate them from the evidence Orca actually has: the live app-server probe
saw `agentsStates` arrive empty, so nothing reads it; and `collabAgentToolCall`
stays substantive because nothing guarantees a session reports subagent work as
`subAgentActivity` at all — one that only emits the collab tool call gets no
roster row, and suppressing that too would leave its fan-out blank.

Same behaviour, same tests; comments and one test name only.

* fix(native-chat): stop the roster's durable twin from claiming live subagents

The spawn-group row is written once and revised in place, but the row itself
is durable and replayed on every reconnect. Its plain-text twin — the only
thing a client that cannot draw the block ever sees — froze a live count into
that row: `Kicked off 4 subagents — 2 working`. The desktop renderer never
shows it, and reconciles the block's `working` to `unverifiable` outside the
live turn. A text-only reader does neither. When the writing process dies
mid-flight the turn-end sweep never runs, so the sentence keeps asserting two
running children forever, with nothing left that could re-check them. That is
the collapse `docs/reference/ssh-execution-boundary.md` forbids: loss of
contact reported as a live state.

Fix it at the source rather than per client: the durable sentence now states
only what survives its process — that the group was spawned, plus whatever
outcome had latched. `Kicked off` vs `Ran` stays, because it reports whether an
outcome was recorded at write time; saying `Ran` while children were in flight
would assert they exited, the same error inverted. The adverse count stays so a
failing fan-out still reads as failing. Reconciliation stays in the renderer,
where the block still needs it.

The twin recognizer keeps matching the legacy `— N working` shape: journals
already hold those sentences and their rows replay forever, so dropping the
branch would print every one of them twice, once as the block and once as prose
the reader meant to drop.

Also align the two functions that read `agentPath`. The root check compared the
raw string while the label normalized separators, so `/root/` was both the turn
itself and a child of it — a phantom row labelled `root` inflating the group by
one. Compare normalized segments instead, keeping `/morpheus` a child. And a
trailing segment with nothing visible in it survives the empty-segment filter
and would draw a nameless row, so it now reads as no label and falls back to the
placeholder.

* fix(codex): key the subagent label collision ordinal on what the row draws

`codexSubagentLabel` tested the trailing segment trimmed but returned it
untrimmed, and `claimLabel` keys its collision ordinal on that string. Two
children at `/root/read` and `/root/ read ` therefore both drew as `read`
with no ordinal — the one thing the ordinal exists to prevent. Return the
trimmed segment so labels that render identically collide.

Also correct the legacy-clause note on the twin recognizer. It claimed shipped
journals hold the old `— N working` sentence; the feature is unreleased, so the
only journals holding one are dev worktrees of this branch. The branch still
earns its place — those rows replay too, and it adds no false-positive surface
the bare shape does not already carry — but the stated reason was wrong.

* test(native-chat): retire the subagent-visibility guards now the roster renders

Two tests from the sibling item-coverage PR asserted that subagent items stay
on the generic gray row, explicitly gated on "until a real renderer exists".
This branch is that renderer, so both guards fire on merge — the handoff they
were written to mark rather than a regression.

They now pin the other side of it: subAgentActivity is suppressed because the
spawn-group roster renders it, and collabAgentToolCall deliberately stays
visible, since nothing guarantees a session reports subagent work as
subAgentActivity at all.

Git merged both files without conflict; only running the suite surfaced this.

* fix(native-chat): let a subagent swept at turn end still report what it did

The turn-end sweep marks still-running children `unverifiable`, and the
producer latched on any state that was not `working` — so `unverifiable`
latched too. A subagent that outlived its turn then reported `completed`, the
latch refused it, and a child that finished successfully read as one we never
saw finish, permanently.

One predicate was doing two jobs. `isTerminalSubagentState` is right for
counting — `unverifiable` is not working — and wrong for latching, because
`unverifiable` records that we stopped being able to see the child, not what
it did. Split them: a child's own verdict latches, the sweep's guess does not.

The reverse stays refused. Nothing returns to `working` once we have given up
on it, so a straggler progress tick cannot re-light a settled row.

Neither the latch nor the sweep was wrong alone, and both were tested; the
defect lived only in their interaction, and only when a subagent outlives its
turn — which the probe that drove this design never produced, because the
parent it captured waited on its child.

* fix: drop the @pnpm/exe lockfile drift a merge staged

`git add -A` swept up the pnpm-lock.yaml mutation that every pnpm invocation
leaves in this repo. Nineteen lines, thirteen of them @pnpm/exe, and it fails
sixteen unrelated CI checks — native smoke, typecheck, packaging, xterm patch
sync — none of which name the lockfile.

* fix(native-chat): restore the item fall-through an inline dropped

Inlining the subagent routing helper lost its null check: the roster returning
null means it did not claim the item, and the translator must keep looking.
Returning unconditionally once any thread item parsed swallowed every ordinary
item — twelve settlement tests, none of them about subagents.

* fix(orchestration): rebind the subagent block arm to the renamed bound state

Main renamed clipMetadata's second parameter from a warnings set to a
TranscriptBoundState. The subagent-group arm still passed `warnings`, and git
merged both sides without a conflict because the lines never overlapped — the
rename and the new arm are in different hunks. Typecheck was the only thing
that could catch it, and did.

* fix(codex): publish the turn tail for a subagent item the roster claims

Main's #19055 added a `subAgentActivity` arm to the provider activity table,
which is reached only through `publishActivity`. The roster's admission returned
above that call, so every `subAgentActivity` item bypassed it and a fan-out that
reports nothing else left the turn tail stuck on the previous frame's text.

`publishActivity` already no-ops on a refused admission and on a non-primary
thread, so routing the roster's admission through it is safe.

Also corrects a docstring the frames extraction copy-pasted onto
`settleOversizedNotification`.

* fix(native-chat): bound the subagent roster on every boundary that carries it

The spawn-group arm was the one collection in the worker-transcript payload with
no cap, and the one block type mobile's `sanitizeBlock` forwarded verbatim. The
producer's `MAX_CODEX_SUBAGENTS_PER_GROUP` does not reach either boundary: the
journal schema declares no maximum on `agents`, and a remote host may run a build
with a different cap. Both transports now cap the roster and bound `id`, `label`
and the open `state` string; `label` and `id` also take the standard inline bound
on the journal write path, where every other provider string already does.

A token count is now persisted onto its entry at write time. `write` rebuilt
`tokens` from the LRU-capped thread map on every write, so an eviction silently
retracted a count the durable row had already shown.

Adds the first coverage of the three roster caps, including the group eviction
that rewrites a row from N children down to one.

* fix(native-chat): keep the roster drawn beside tool calls and its clock honest

The roster-only escape is keyed on `blocks.length === 0`, so a spawn group
sharing its message with tool-call blocks fell through to the settled-turn guard,
which returned bare null and took the roster with it — the exact regression the
escape above was written to avoid, after the message row had already counted the
group as renderable. Unreachable for Codex today; the block type is deliberately
provider-agnostic, so it is live for the Claude lane.

The elapsed clock also froze at a sibling's timestamp on a partial sweep: in a
group where one child completed and another is unaccounted for, the ended turn
left `working === 0` with the completed child's `settledAt`, and the row showed
that child's duration as the group's run length. No clock is drawn while any
child is `unverifiable` with no terminal timestamp.

* perf(native-chat): bound the roster's provider strings without digesting them

`boundInlineText` computes a sha256 and a Buffer BEFORE it checks the length,
so the roster paid two digests per child on every write even when nothing was
truncated — and `write()` runs on every claimed activity item (each delivered
twice) and again from `handleTokenUsage`, which streams. A same-process A/B over
a 64-child group: 76.5 us/write before, 2.0 us/write after (plain, unbounded row
is 1.2 us).

The cap changes with the mechanism. 16 KB is the tool-output bound; both readers
of this row already clip the same fields to 512, so the producer was admitting
~2 MB per durable roster row for consumers to throw ~97% of away. One
`MAX_SUBAGENT_FIELD_CHARS` now serves the producer and both readers, and the
marker is an ellipsis rather than the tool-output truncation sentence — `id` is
the roster key and the renderer's React key.

Also raises the orchestration arm's per-group bound from 20 to the producer's
64, matching the mobile arm: a 21-64 child group is routinely producible here,
so that arm clipped children and warned while its sibling clipped none. The
slice and warning stay as the transport's own defence against a remote host with
a larger cap.

* fix(orchestration): suppress one roster block per twin, not all of them

`hasTwin` was a single boolean over the whole message, so a message carrying two
`subagent-group` blocks and one plain-text twin printed one sentence and dropped
the second roster with no marker. Count the twins and claim one per group
instead. Not reachable from this branch's producer, which writes one group per
journal item, but the surrounding reasoning is explicitly about wire shapes the
producer never writes and this is the adjacent one it missed.

* fix(native-chat): loop-3 fixes to the Codex subagent worklog

Five defects loop 2's own fixes introduced.

Twin claiming was order-blind: the count-based claim silenced whichever
roster block came first, so a lone twin belonging to a LATER group erased
an earlier group's roster and printed the later sentence twice. Exact-text
claims are now settled for every group before any leftover twin is claimed
by position; the positional fallback stays for a newer build's frozen twin,
which can never equal a recomputed sentence.

`boundSubagentField` sliced UTF-16 units and could leave a lone high
surrogate in a durable row, and the clip removed exactly the tail that told
two children apart — `id` is the renderer's React key and `claimLabel`
writes its repeat ordinal at the end. It now backs off a split pair and
reserves the child index inside the bound, so both readers' re-clip cannot
cut the disambiguator off again.

`MAX_SUBAGENT_FIELD_CHARS`'s doc claimed a `groupId` bound the producer
never applies; the doc now says so and why. The worker-transcript metadata
cap is a separate literal again: it governs message ids, turn ids, tool-call
names and image urls, so a roster-motivated change must not move it.

* fix(native-chat): never infer a lost subagent from a turn boundary

QA drove a real Codex session with three live `spawn_agent` children and sent
a mid-turn correction. The roster row immediately read "Ran 3 subagents /
3 unverifiable" with no clock, while all three were still running — they
reported `completed` 57-87s after that turn ended.

Both sites rested on the same false premise: that a turn ending means no
event will ever settle a child. Children outlive their turn and keep
reporting into the same group.

- Renderer: drop `reconcileSubagentRoster`. Nothing plumbed to the component
  distinguishes a row written by a dead host from a turn that merely ended —
  journal render items carry no epoch, and a new epoch deletes the rows of the
  one it supersedes — so the row now draws the state the journal recorded.
  Under-claiming beats over-claiming.
- Main: stop sweeping on `turn/completed`. That sweep wrote `unverifiable`
  into the DURABLE journal, which mobile reads with no reconciliation.
  `turn/completed` is Codex's only turn-end notification, so an abort cannot
  be told apart from a clean finish; the safe default is not to sweep.

`settleSession` — the provider actually being gone — is unchanged and is now
the only sweep. `unverifiable` stays non-latching so a late verdict still lands.

* test(native-chat): pin the roster at the seam the QA defect came from

The mid-turn correction opens a new turn, so the fan-out's row stops being
the current turn and the list hands the roster `activeTurnIsWorking={false}`.
Asserted through the list, not the component, because that prop is what
carried the wrong claim.

* fix(native-chat): settle a roster the dying host never got to sweep

`settleSession` only fires when the provider goes away while this process is
alive. If the host itself dies, nothing sweeps and nothing reconciles on
restore, so a `subagent-group` row persisted as `working` claimed live children
forever — the mirror of the defect the previous commit fixed, and the same
`ssh-execution-boundary.md` violation in the other direction.

Reconciled host-side, at journal open, not in the renderer: mobile shows only
the durable text twin and reconciles nothing, so a renderer-only fix would
leave it claiming live children indefinitely. Opening the journal is also the
one moment a host can honestly say the previous writer is gone.

- `staleSubagentRosterRevisions` rewrites every child still reading `working`
  to `unverifiable` and regenerates the twin from the same summary, so the
  block and the sentence cannot disagree.
- No terminal timestamp: the child stopped being observable at an unknown
  moment, and stamping the reopen would report the downtime as its run length.
- Revises in place under the parsed identity, so a reopen upserts the row
  rather than appending a duplicate, and a second reopen writes nothing.
- Skipped on a corrupt load: that journal is still owed a rebuild from provider
  history, and content past the repair's free sequence retires the demand.

Reconciles journal ROWS, not roster state — the producer's in-process group map
is untouched, so the roster's known seeding limitation is unchanged, as is
`canReplaceSubagentState`: `unverifiable` still does not latch.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-07 09:38:16 -07:00
Brennan BensonandMerge Sim bffdad9f05 fix(native-chat): make structured chat tabs renameable (#19153)
* fix(native-chat): let a structured chat tab be renamed

Renaming a native chat tab accepted the text and silently did nothing:
setTabCustomTitle only scanned terminal tabs and only bridged to unified
tabs whose contentType was 'terminal', so the agent-session tab it was
keyed to never matched. Any label that did land was then re-nulled by the
next host snapshot, which preserved color/createdAt/isPinned but not
customLabel.

Also routes both placeholder sites through one helper so a Claude chat
stops falling back to 'Codex Chat'.

* test(native-chat): cover structured chat tab rename and label fallback

* chore: drop the local @pnpm/exe lockfile artifact

Swept in accidentally; running pnpm here adds @pnpm/exe to the root
lockfile, which fails CI's frozen-lockfile guard.

* fix(native-chat): reach the rename shortcut and tab color too

Review found the first fix covered only the context-menu path. The
tab.rename shortcut gated on activeTabType === 'terminal', so on a
structured chat tab it stayed the silent no-op this branch set out to
fix. setTabColor carried the identical terminal-only lookup one function
below the one that was fixed.

Both lookups now share one resolver instead of two copies.

* fix(native-chat): stop unknown agents reading as Codex, cover the terminal path

Review found the placeholder helper encoded "unknown means Codex": its
signature accepts null/undefined and Tab.agentSessionAgent is the open
AgentType, so the first caller passing a Tab would label gemini or grok
as "Codex Chat". Routed through the shared agent-name table instead.

Also adds the missing regression test that a terminal rename still
resolves through its entityId now that both rename and color share one
resolver, and a guard on a test that passed with the fix reverted.

* fix(native-chat): degrade instead of throwing on a null tab title

A stacked branch can publish title: null when a conversation name is
cleared. The wire type says string, so this consumer trusted it and
threw inside the store patch that applies the snapshot. Fall back to the
placeholder — the producer bug is fixed separately, but a consumer of
wire data should not crash on a contract violation.

* fix(native-chat): rename the focused structured tab, not a background terminal

* fix(native-chat): cycle terminals from the structured tab, not a stale terminal

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-07 09:36:14 -07:00
a899f92402 feat(windows): enable structured Codex chat on native Windows (#18519)
* feat(native-chat): enable Windows structured sessions

* fix(codex): prove native Windows process identity

* style(codex): format Windows session seam

* fix Windows structured Codex admission

* fix(windows): reprobe missing process identity capability

* fix(windows): decide folder-workspace WSL routing before the click

Review found pathUsesWslUnc exported but unused, and the folder composer
hardcoding worktreeUsesWslPath:false. Together those meant a folder picked
under a \\wsl.localhost\ parent routed to structured chat, then got refused
by the host and fell back AFTER the click -- which defeats the lane's own
design goal that create cannot fail after the click.

The group's parentPath is in scope at submit and the workspace is created
under it, so the parent decides WSL-ness pre-click. Wires pathUsesWslUnc
there and adds tests for the helper, including the unhydrated-store case
that previously threw.

* fix(windows): collapse the gate derivation to one call, restoring max-lines

CI static analysis failed: launch-agent-in-new-tab.ts crossed the 300-line
oxlint ceiling. Adding a max-lines disable is forbidden, so the two gate
derivations collapse into one readWindowsStructuredGateInputs() call --
a store-backed site now adds one line and one import name instead of two.
Better shape anyway: one derivation entry point rather than two reads a
call site must remember to pair.

* fix(windows): engage the legacy fallback when the host THROWS a refusal

Review found a P1 this merge composes: neither parent could reach it. At the
lane head the only structured entry was launch-agent-in-new-tab (full
store-backed WSL check); on main all win32 was refused. The merge enables
win32 in creation flows that pass no projectRuntime, so a WSL folder
workspace, a WSL-configured repo, or a repair-required runtime now routes
structured -- and the host refuses correctly, but by THROWING rather than
returning {ok:false, refusal}.

Callers engage their legacy-terminal fallback on the refusal CLASS, so an
unmapped throw arrives as a generic RPC rejection: no fallback, empty
workspace, error toast, prompt stranded in the launch outbox. Pre-merge the
same action opened a legacy terminal agent.

Map the host's thrown definitive refusals onto the refusal class at the
launch boundary, so every creation flow -- present and future -- degrades to
the legacy terminal instead of stranding. Narrow predicate: unrelated
failures (ECONNRESET, empty message, non-Error) still propagate untouched.

Ablation-proven: removing the mapping reddens the fallback test.

* fix(windows): teach the mobile RPC double the status probe the lane added

CI's first-ever run on this lane caught a pre-existing lane defect. The lane
changed status.get to resolve through
runtime.getStatusAfterWindowsProcessStartTimeProbe(), but never taught the
mobile-surface runtime double about it, so status.get failed for mobile
clients with "not a function". The lane's own test list did not include this
file and the lane had zero CI, so nothing ever ran it.

The real runtime always implements the method; the double omitted it.

* chore: merge current main and regenerate the localization runtime catalog

CI static analysis failed on a stale en-runtime-required.json: main added
onboarding integration-capability keys, and the generated catalog is checked
against the PR MERGE result, not the branch alone -- so it read clean locally
while failing in CI. Merging current main (90780acb85) and regenerating.

Gates after the merge: pnpm tc 0, oxlint 0, changed-code quality 0/56,
7 gate/lane test files 69 tests green.

* fix: route structured launches by execution host platform

* fix: recover paired structured session mirror on host swap

* Revert "fix: recover paired structured session mirror on host swap"

This reverts commit 81bfca0007.

* Revert "fix: route structured launches by execution host platform"

This reverts commit 47abbd354a.

* fix(windows): refuse structured chat in a paired web client

Reverts the two review-loop commits (restoring a tree byte-identical to the
validated head) and closes the hole they were aiming at, without their cost.

A paired web client's `platform` describes the browser's machine, not the host
that will run the agent, so the Windows gate cannot be evaluated there. Before
this, a browser on macOS driving a Windows runtime read "not win32", skipped the
creation-time proof entirely and allowed structured chat — fail-OPEN, the
dangerous direction, bypassing the guarantee this lane is built on.

`isWebClient` is a required input like the other gate fields, so the compiler
enumerated all seven call sites. Refusal is synchronous and fail-closed: no
async round-trip, no null window, no cache to invalidate — unlike keying on an
asynchronously-fetched host platform, which would have made every desktop
launch wait on a round-trip to fix a paired-web-only hole.

Paired web therefore gets the legacy chat until the host publishes eligibility
itself; that is the proper fix and belongs in its own PR.

Ablation-proven: removing the guard reddens both refusal tests; the
desktop-unaffected test is a preservation check and passes either way.
Gates: tc 0, oxlint 0.

Known open: repos-onboarding-folder-startup.test.ts fails on this branch and
passes on plain main — under investigation, NOT caused by this commit.

* test(onboarding): mock the web-client check the store path now reaches

The web-client refusal added `isWebClientLocation()` to the launch-route
inputs, which this suite's store path reaches while adding the FIRST folder.
The suite stubs `window` as `{ api }` with no `location`, so the function
cleared its `typeof window === 'undefined'` guard and then threw on
`window.location.pathname`.

That threw inside addNonGitFolder's own catch, so folder-1 never activated;
folder-2 then returned early (a project already existed) before reaching the
call at all, leaving exactly one activation with no startup seed.

Test artifact, not a product defect: a real renderer always has
`window.location`, so the seeding path is intact for users. Mocking the module
is the convention 7 other suites already use, and keeps product code free of
defensive branches that only exist to satisfy a stub.

Ablation-proven: removing the mock reproduces the original failure exactly.

* fix(renderer): make the web-client check total over a partial window

isWebClientLocation() guarded `typeof window === 'undefined'` and then assumed
`window.location` existed. A window stubbed without a location cleared the
guard and threw on `.pathname`.

That matters because this branch put the call on the launch-routing path,
where the throw is swallowed by the caller's catch and silently becomes a
FAILED LAUNCH rather than a visible error. CI caught it as 9 failures in
launch-work-item-direct.test.ts.

I previously "fixed" this by mocking the module in the one suite I knew about.
That was whack-a-mole against an unbounded set, and it missed this one. The
defect is the partial-window assumption, so fix it there: the mock is removed
from the onboarding suite and both suites now pass on the hardening alone.

Ablation-proven: reverting to the unguarded form reddens 11 tests across the
new unit suite and launch-work-item-direct.

Gates: tc 0, oxlint 0, changed-code quality 0/58.

* Move Codex's Windows structured-chat eligibility onto the host createSupport probe

The renderer no longer decides Codex win32 eligibility: launchStructuredAgentSession
probes agentSession.createSupport for both providers, the host answers via
supportsCodexStructuredLocation (process start-time proof + WSL refusal), and the
create path re-checks live. Deletes the client-side windows gate module and its
routing inputs (windowsProcessStartTime, worktreeUsesWslPath, isWebClient, platform)
from six call sites. Splits killCodexAppServerProcessTree out of
codex-app-server-session to hold the max-lines ceiling without a disable.

* fix(ci): keep pnpm lockfile stable

* test(windows): align foreground snapshot flags

* Restore main's pane-snapshot flag contract

Main asks for CreationTime on both projections; this branch's hot-path
isolation went away with the async probe it served.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Merge Sim <sim@local>
Co-authored-by: Merge Sim <merge@localhost>
2026-09-07 09:18:38 -07:00
Jinwoo Hong fede3eb2ff fix(test): give federation tests a real read-after-write sync barrier (#19262)
`syncOrchestrationFederation()` coalesces onto an already-in-flight relay-tick
sync, which may have pulled from the peer before the caller's mutation existed.
Tests used it as a barrier, so `keeps a timed-out remote question resumable`
could reply against a home DB that had never imported the worker's question:
the reply failed with `Message not found`, no `to_worker` relay was enqueued,
and the resume ask surfaced it 5s later as a spurious timeout.

Add `syncFederationBarrier()`, which chains each active dispatch past the
current round via `syncOrchestrationFederatedDispatchAfterCurrent`, and use it
at every barrier-purpose sync site. The two tests whose subject is the sync
machinery itself keep the raw call. Also assert the reply response, so a failed
reply fails at the reply instead of masquerading as a timeout.

Production is unaffected: `syncOrchestrationFederation` has no production
callers, real read-after-write paths already use the after-current sync, and
relay ticks retry every second.
2026-09-07 04:54:28 -04:00
Jinwoo Hong a3e67365a3 fix(orchestration): recover Codex idle after completion title race (#19243)
* fix(orchestration): recover Codex idle after completion title race

* test(native-chat): enable structured sessions in adoption replay fixture

* test(orchestration): cover deferred pointer recovery after prolonged unknown status

* fix(orchestration): fence completion recovery by process generation
2026-09-07 04:35:56 -04:00
Neil c3a70082c6 Fix MiniMax credential-expiry reporting, region sync, and refresh (#19250)
* Fix MiniMax credential-expiry reporting, region sync, and refresh

Three defects from #14929:

1. The usage endpoint answers an expired cookie or key with HTTP 200 and
   base_resp.status_code 1004, never 401/403 (confirmed against both regional
   hosts). The stale-token branch was therefore unreachable, so expired
   credentials surfaced as 'usage-unavailable' with the raw upstream string,
   and stale policy kept showing old numbers as if the failure were transient.
   Classify 1004 as an expired credential.

2. minimaxEndpoint reached the SettingsUpdate schema and the web store but was
   never projected by RuntimeClientSettingsController.get(), so a paired client
   fell back to 'overseas' regardless of the host's region and rendered the
   wrong console link. Add it to the projection and the store contract.

3. Changing the region persisted without refreshing usage, leaving the previous
   host's snapshot in the status bar until the next poll. Invalidate and refetch
   when the endpoint, group id, or model list changes.

The RPC-level tests mock the controller, so the projection had no real
coverage; the new test fails against the pre-fix projection.

* Localize the MiniMax credential-expiry copy

Classifying 1004 as stale-token made the status bar show the raw English
error verbatim: the new wording matches none of USAGE_AUTH_ERROR_PATTERNS,
whereas the old upstream text ('...log in again') matched and was replaced
with localized copy. That traded a localized-but-misleading message for an
actionable English-only one, which is the wrong trade for the CN users this
work targets.

Tag the error with credentialSource so the renderer can pick the right
localized string per credential kind, and add the three catalog entries.
2026-09-07 01:22:08 -07:00
Neil ffff6eaca2 fix(test): admit the adoption-replay create fixture through the structured gate (#19246)
Semantic conflict between two green PRs. #19176 added this replay test while
`agentSession.*` still admitted a `runtime` client on its negotiated capability
alone; #18700 then made `experimentalStructuredNativeChat` one rule for every
caller. Neither branch saw the other, and main runs no post-merge test gate, so
`agentSession.create` started refusing at the envelope level and the test's
`ok: true` expectation broke.

#18700's rule is the intended behaviour and `create` starts work, so it belongs
behind the gate. The fixture is what is stale: it builds a real
`OrcaRuntimeService` whose client settings are unset. Enable the setting the way
#18700 already did for the sibling pre-commit fixture. The assertions about
durable-identity replay are untouched and now actually run.
2026-09-07 01:01:49 -07:00
Neil 2ccf35b135 fix: avoid quadratic trimming during fullscreen terminal redraws (#19214) 2026-09-06 23:41:49 -07:00
Brennan BensonandMerge Sim fa5ef99885 fix(native-chat): settle structured chat turns stranded by a restart (#19122)
* fix: settle structured chat turns after restart

* fix: preserve unconfirmed turn cancellation state

* test: preserve unconfirmed turn lifecycle

* test: narrow unconfirmed cancellation coverage

* fix: keep intentional TUI closes out of recovery

* test: keep branch rename journal mock current

* fix: settle dead TUI handoffs before reacquire

* fix: preserve handoff stage after retry settlement

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 23:34:50 -07:00
Brennan BensonandMerge Sim ba4e79c250 fix(runtime): apply the structured-chat setting to every RPC caller (#18700)
* fix(runtime): apply the structured-chat setting to every RPC caller

supportsStructuredAgentSessions only consulted experimentalStructuredNativeChat
when clientKind === 'mobile', so identical host settings admitted desktop and
in-process callers while refusing a phone. The server branched on client surface.

The setting is now one rule for every caller. The negotiated capability stays a
wire term asked of remote clients only, so a capability-less in-process caller is
still admitted on the setting alone.

Making the projection's structuredNativeChatEnabled argument required surfaced
eight call sites that passed `undefined` for non-mobile clients; they now read the
host setting, so tab projection follows the same single rule.

Announced behaviour change: with the flag off, session.tabs.list/listAll no longer
restore structured tabs for desktop. The desktop renderer already discards them in
that state, and startup record/lease reconciliation is unaffected.

* fix(runtime): keep structured session cleanup available

* test(runtime): enable structured chat in desktop projection fixture

* test(agent-session): settle merged fixtures against the all-clients structured policy

The merge with main left three fixtures written for the old mobile-only rule:
a duplicate getClientSettings key, a create fixture with no host settings at
all, and a projection call whose 'old client' is now the mobile fallback-title
case.

* fix(native-chat): let an admitted caller close a chat after the setting is off

Turning `experimentalStructuredNativeChat` off revoked admission for every
`agentSession.*` method, including `close`. A chat opened while the setting was
on stays mounted, so its owner was left with a live provider child and an X
button that answered `structured_agent_session_unsupported`.

Split the surface by what a method does to work in flight rather than by how it
sounds, and write that rule where the gate lives so the next method lands on the
right side: starting, extending, retaining or reading needs admission; stopping
or retiring work the caller already owns does not. Moves `close` and `cancel`
onto the cleanup gate alongside `unsubscribe` and `release`.

The tightening is unchanged - the cleanup gate still demands the negotiated wire
capability and never creates a host, so an incapable client still cannot see the
surface and no method that starts work is reachable with the setting off.

Extracts the dispatcher harness and the method-to-gate table into fixtures so
the new admission suite can share them without a max-lines disable.

* Drop a duplicate lastActivityAt key carried in from main

The main commit this branch merged (fb322046e8) had two lastActivityAt
properties in the same object literal at both journal stubs, which fails
TS1117 and oxlint. Upstream has since kept only the later value; match it.

Not introduced here, but merged in, so it has to be fixed here.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 23:32:00 -07:00
Brennan BensonandMerge Sim 1ae7aa8bb4 feat(native-chat): resume an Agent Session History row into a new structured chat (#19176)
* feat(native-chat): resume an Agent Session History row into a new structured chat

A Claude or Codex row in Agent Session History gains "Resume in New Chat": it
opens a new structured native-chat tab that continues that provider
conversation, with the prior turns already in the journal. Until now those rows
could only be resumed into a PTY terminal; the structured branch could reveal a
chat Orca already owned but could not adopt one it had never held.

Almost all of the machinery existed. Both lanes already resume from the record's
provider handle chain, the journal already has a transcript importer, and the
handle chain already models `adopted` as an origin. The gap was that a create
always minted an empty chain, so the adapters started a fresh conversation. This
seeds that chain.

The client names only the conversation. `agentSession.create` is reachable by
paired mobile clients, so the transcript path and the account home are derived
by the executing host and validated against the account homes it recognises —
a client-supplied path would choose which file the host imports and which
credential directory the provider child launches against.

Failure refuses rather than degrades. A transcript that cannot be found refuses
before anything is created; one that fails or decodes empty *after* the provider
has resumed fails the attach, tearing the child down and publishing no tab,
because an empty journal beside a context-carrying agent claims a continuity the
provider never gave.

Codex can resume into any workspace since it is handed the rollout path; Claude
resolves transcripts under a project key derived from the launch cwd, so it is
offered only for the workspace the conversation was recorded in.

* fix(native-chat): widen adopted-home discovery and keep ordinary launches untouched

Three corrections from review of the first commit.

The adoption's account-home candidates now include the extra Codex homes session
discovery already scans. A row this host listed could otherwise refuse to
resume, which reads as the feature being broken rather than as a scope.

Ordinary launches call `createStructuredAgentSessionLaunchIntent` with two
arguments again. Passing the resume source unconditionally appended a trailing
`undefined` that four existing call-site assertions had to absorb; the churn was
the caller's fault, not the tests'.

The transactional adoption guard's comment claimed the self-exemption is what
lets a committed create replay. It is not: replay is settled earlier by the
operation ledger, and an adoption always arrives with a null expected fence, so
a request naming an existing session id is refused a few lines below either way.
The exemption is part of what "another record" means, and the comment now says
that instead.

* fix: preserve history adoption through create and retries

* fix: replay committed history adoption from durable identity

* fix: validate history before claiming adopted sessions

* fix: extract AI vault resume domains

* fix: recognize typed history resume refusals

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 23:24:21 -07:00
Jinwoo Hong d53cbed43f revert: hold mobile push feature for user testing (#19203)
Reverts 3160b54c69. Restore through a separate draft PR after user validation.
2026-09-07 00:30:21 -04:00
Brennan BensonandMerge Sim f1d8545024 feat(chat): support structured /clear and /compact commands (#19164)
* feat(chat): support structured clear and compact commands

* fix(chat): authorize mobile commands and bound clear-chain projection

* fix(chat): localize conversation command send errors

* fix(chat): retain clear pane identity with reopened history

* test: account for combined structured session RPC additions

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 21:17:00 -07:00
Brennan BensonandMerge Sim bf4e270504 fix(native-chat): list the slash commands and skills a structured Claude session actually loaded (#19127)
* fix(native-chat): list the slash commands and skills a structured Claude session actually loaded

The chat composer's `/` menu was built from a curated five-command catalog plus a
host disk scan of skill roots. Neither is what the running session can do: the
session reports its own `/` surface, which carries this repo's `.claude/commands`,
the skills that only reach it through plugin roots, and a hide-list of commands
that mean nothing outside a terminal UI. On one local session the menu offered 6
commands and 17 skills where the session reported 62 commands and 33 skills.

Read that surface per session and let it drive the picker:

- A per-session catalog seeded from the frame that proves the session and kept
  current by every later report, exposed over a new `agentSession.commands` read.
- The report is the authority on WHICH skills exist; the disk scan stays the
  source of scope and description for the names both know about, so a skill the
  session never loaded is no longer offered and one it loaded from a root the
  scan cannot see now is.
- A host that predates the read answers `method_not_found` and the composer keeps
  its curated catalog, so mixed versions and the PTY lane are unchanged.

* test: register agentSession.commands on the three surface ratchets

The structured method count, the mobile allowlist, and the cross-version call
table each enumerate the agentSession surface on purpose, so an additive method
has to be declared in all three rather than counted around.

* fix: preserve session catalog authority and publish live updates

* fix(native-chat): publish authoritative command catalogs on session updates

* fix: seed Claude slash catalog before the first prompt

* test: verify unclassified catalogs survive session publication

* test: complete structured rename journal fixtures

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 20:33:14 -07:00
Brennan BensonandMerge Sim 546fd9b21f fix(native-chat): remember structured chat model and effort picks (#19147)
* fix(native-chat): remember structured chat model and effort picks

Structured Claude and Codex sessions already read the saved launch
options at create, but nothing ever wrote them back. The only writer of
`nativeChatSessionOptions` was the PTY picker, and the composer swaps in
the structured surface for structured panes, so a structured pick went
nowhere: it was forgotten when the session ended and every new session
started at the CLI default.

Persist a settled pick from both the desktop and mobile structured
surfaces. Model and effort are stored as a pair, because a launch
resolves a stored effort only under a stored model — so an effort-only
pick adopts the model it was chosen against, otherwise the remembered
effort never reaches a launch at all.

Two things the persist path deliberately avoids: it writes what the
provider committed rather than what was requested, since Codex
reconciles an effort the newly selected model cannot run; and it never
writes the provider readback, which is the CLI's own default and would
pin a `-m` the user never chose.

* fix(native-chat): persist session option picks atomically

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 20:28:17 -07:00
Brennan BensonandMerge Sim 68dd3909c7 feat(orchestration): orchestrate native-born structured chat sessions (#18827)
* feat(orchestration): orchestrate native-born structured chat sessions

Orchestration resolves every worker through a terminal handle and a pane key
backed by a live PTY. A session created directly as structured has neither, so
it was not refused by orchestration — it was invisible. A coordinator could not
start one, address one, or receive `worker_done` from one.

Add a second authority source rather than a parameter channel. A registry maps a
session id to the same three facts the PTY path supplies — a bearer handle, a
pane key and a host scope — and the four runtime getters consult it before
giving up on `ptysById`. `orchestration.send` and `verifyDispatchCapability` are
untouched: authority stays host-derived and the CLI still cannot assert who it
is. PTY handles short-circuit on the handle prefix, so the terminal path is
unchanged.

Mail travels as a session turn instead of as bytes, on a sibling lane that keeps
the PTY lane's outstanding-run, waiter, reserved-type and batch rules.
Orchestration's database stays the source of truth; the send is best-effort,
exactly as the byte write is, and mail is consumed only on a proven-accepted
dispatch. Delivery waits for the session to be between turns, because one
provider refuses a mid-turn start outright and the other cannot acknowledge one
inside the ack window.

Security properties, each pinned by test: the pane key's leaf is random and
persisted rather than derived, since `check` is identity-gated and accepts a
caller-supplied pane key; the handle is a random bearer token; the child env
carries no pane key, which would otherwise flow into hook pipelines that assume
a PTY leaf; hook attestation stays closed for structured handles; and process
continuity comes from record lineage, never the runtime fence, which the host
bumps during its own crash recovery.

Also remove the "Orchestration paused" notice, which gated only on dispatch
status and rendered over bridge chat where orchestration always worked; refuse
the implicit-sender fallback when a worktree has more than one candidate leaf
instead of guessing; and collapse the archive kinds to one named type with a
compile-time assertion that the capture set cannot drift ahead of the storable
set.

* fix(orchestration): answer the structured idle gate from the reduced timeline

The structured pointer gate read a bounded 40-item tail page. A settled turn is
tombstoned rather than rewritten, so an idle worker with any real history carries
no turnLifecycle item at all and the "full page, no lifecycle item" guard read it
as busy forever: every nudge after the worker's first substantial turn parked on a
settle edge that had already passed, and the preamble tells workers not to poll.
The attention gate had the mirror bug — a prompt older than the tail window was
missed and the nudge was delivered into a session blocked on a human.

Both facts now come from `journal.snapshot()`, the fully reduced timeline, via a
new narrow `readGateFacts` host read; the policy module stays pure and still
projects through the shared helpers the chat view reads.

Also:
- Park `session-not-attached` on the journal edge, so mail that arrives during a
  transient detach is redriven by the re-attach reset instead of sitting unread.
- Resolve a structured worker's provider from the durable agent-session record
  when the registry entry was rehydrated, so a restarted Codex worker is no longer
  reported and archived as Claude.
- Clear `structured_pointer_operations` in every `orchestration reset` scope.
- Drop the per-chat-pane dispatch-status store subscription left behind by the
  removed paused notice, and re-pin the two terminal-pane ratchets it moves.
- Hoist the identical pointer batch selection out of both delivery lanes into
  `selectOrchestrationPointerBatch`.
- Refuse the pre-graph-ready focus-based guess for `requireUnambiguous` callers,
  matching the ready path.
- Move the host teardown phase list into the teardown module it belongs to, which
  is what keeps the host inside its max-lines budget.

* fix(orchestration): discard a structured worker session whose create settled unknown

`commitStructuredAgentSessionCreate` answers `agent_session_operation_unknown` when
`attach` SUCCEEDED and only the tab publish failed, so `created.ok === false` is not
proof that nothing exists. The worker start read it that way and skipped
`discardCreatedSession`, leaving a live provider child that took no hold, has no
`bindingsByDispatchId` entry and no published tab — the outer
`releaseStructuredWorkerSession` no-ops without a binding, and a session that never had
a holder never starts the eviction clock, so nothing in the runtime ever retires it. A
throw out of the commit half is past `attach` for the same reason; the pre-commit half
refuses rather than throwing. Cleanup now asks whether the create MAY have committed,
via the existing `isDefinitiveAgentSessionCreateRefusal` predicate.

Also:
- Strengthen the pre-ready `requireUnambiguous` test so it actually pins the guard: the
  snapshot now carries a focused terminal, so deleting the `? [] :` ternary turns the
  test red instead of leaving the refusal to the ambiguous `listTerminals` fallback.
- Correct the guard's justification comment, which cited `orchestration check` as
  covered. `check` resolves through the `--terminal` scope and still guesses; the
  guard covers the implicit `--from` sender, and a structured worker is covered by the
  `ORCA_TERMINAL_HANDLE` baked into its child.

* docs(orchestration): stop two structured-worker comments claiming guarantees the code does not give

The send-time owner re-check reads `target.refusal`, the snapshot the resolver
already admitted, so `decideStructuredPointerDelivery` can only agree with the
resolve-time answer and `owner-not-settled-native` is unreachable from that call
site. What actually fences an owner that moved is `expectedRuntimeFence`, which a
handoff bumps. Say that, so nobody later drops the fence trusting a re-check that
is structurally a tautology.

`discardCreatedSession` was credited with retiring "a published background tab
that no dispatch owns". It hides the DURABLE tab reference and closes the
session; the live tab snapshot keeps the row, so the background tab this start
published stays on screen until the app restarts. Same for stop and release. The
comment now describes what the two calls do — including that both are no-ops on a
session that was never attached, which is what makes the non-definitive-refusal
path safe to reach unconditionally.

* fix(orchestration): retire a structured worker's chat tab when the worker settles

Starting a structured worker always publishes a real `agent-session:<id>` tab, but
every settlement path only called `setSessionTabVisibility(sessionId, false)` plus
`host.close(sessionId)`. That clears the DURABLE restore index and leaves the LIVE
snapshot untouched, so stop, release and the half-started discard all left a dead
"Claude Chat" / "Codex Chat" tab in the worktree's tab bar for the rest of the app
session — five dispatches, five dead tabs — and opening one re-attached the released
session, respawning a provider child outside orchestration's hold accounting.

The snapshot-pruning half of `closeStructuredAgentSessionTab` is extracted into
`structured-agent-session-tab-retirement.ts` and exposed on the runtime as
`retireStructuredAgentSessionTabFromSnapshot`, so the user-initiated tab close and
the three settlements share one implementation instead of a second copy.

The settlement side is best-effort BY CONSTRUCTION: it runs only after the close is
already proven, calls the runtime method optionally, and swallows any throw. It
talks to no renderer, so the startup release reconciler can call it too. Nothing
here can turn a proven stop into `release_unknown`.

* fix(orchestration): stop a structured worker's nudges, archive and liveness from lying

Five defects in the structured-worker lanes, each with the same shape: a check
that answered from something other than what it claimed to measure.

- The pointer lane gated a WORKER's `dispatch:` mailbox on its RUN's outstanding
  delivery. Delivery rows exist only for a `run:` address, so that row belongs to
  the coordinator — and a coordinator holds one for exactly as long as it is
  acting on received mail, which is when it replies to its workers. The gate is
  gone; there is no coordinator mailbox in this lane to protect.
- `dispatch-rejected` now parks on the journal edge. A rejection consumes no mail
  and nothing else redrives the mailbox, so an unparked pointer left the worker
  idle on durable mail until unrelated mail happened to arrive.
- The released journal archive bounded forward — keeping the HEAD — before
  capping newest-first, so a long worker's archive ended at its early exploration
  and dropped the answer it was released for, under a warning that said the
  oldest messages had gone. One newest-first pass now, and the warning is true.
- The durable pointer operation id was reused on a matching BODY fingerprint, and
  the body names only the unread count. Two unrelated same-size batches collided,
  the host replayed its ledger answer as `accepted` with no turn sent, and the
  lane marked the new mail delivered. Reuse is keyed on the batch's message ids.
- `worker-read` on a structured worker hardcoded `terminal: 'running'` and
  emitted no `liveness`, so a runtime that could not see the session reported the
  worker as alive. It now carries the observed verdict, as the PTY branch does.

Also: the live journal cursor is an index into a re-derived tail window, so the
page's oldest item joins its source identity — a slid window now answers
`source_changed` instead of silently resuming past the items it skipped. And a
stop that reached no host reports `processAction: 'none'`, after installing the
host the way release already does.

* fix(orchestration): stop a released structured archive claiming a close that never landed

`worker-read` on a released structured worker hardcoded `liveness: 'exited'`. The
archive is frozen BEFORE the close, so it proves nothing about the provider child,
and the read is served for `release_state` in `releasing` / `unknown` too — the two
states that exist precisely to record a close that did NOT land. A coordinator that
read `exited` from a `release_unknown` worker would start a replacement over the same
worktree while the original child was still attached, which is the outcome
docs/reference/ssh-execution-boundary.md rule 2 exists to prevent, and it contradicts
the release receipt's own "the structured session close was not proven" text.

The verdict now comes from the resource row the read already holds: only a settled
`released` row is `exited`, everything else is `unverifiable` — which the existing
mapping renders as `terminal: 'unknown'`, the same way the live branch does.

* fix(orchestration): stop a structured worker-start reporting a preamble it never delivered

Two ways a structured `worker-start` handed the coordinator a receipt that did not
describe the worker it got.

`sendStructuredWorkerPreamble` threw only on a refusal and on `rejected`, so a
submission that settled `unknown` fell through as success: the start pushed
`dispatch_input: accepted` and marked the dispatch ready. `unknown` is not rare —
`dispatchSafely` converts ANY thrown adapter call (provider child gone, transport
dropped, ack window missed) into it, and `performSend` still returns ok. The worker
then has no task spec while its coordinator blocks in `check --wait --types
worker_done` until timeout. This PR's own mail lane already states the rule —
"`pending` is not yet an acknowledgement; only `accepted` may consume mail" — so the
preamble now applies it too, and raises `operation_unknown` for the states that
prove neither delivery nor failure, which is the code `failWorkerStartWithReceipt`
turns into the `outcome_unknown` receipt whose nextCommands send the coordinator to
look. `rejected` stays a proven failure.

`--structured` also accepted `--model` / `--effort` and dropped them: structured
session creation takes no launch preferences, while `launch.receipt.effective`
echoes whatever was requested either way, so `--model opus` ran on the workspace
default and the receipt still said `opus`. Refused now, for the same reason
`--terminal` refuses them, and the spec note records that refusal along with the
new-child/new-top-level one it never mentioned.

Tests: the refusal guard had no coverage at all, and `structured-mailbox-pointer-host`
— where the full-timeline gate read lives — had none either; reinstating the bounded
tail there left the whole repo green. Both are covered now, and the vacuous
"never selects an exact provider session" case is re-pointed at the absent
`ORCA_PANE_KEY` that actually keeps that selector shut.

* fix(orchestration): let a structured worker actually reach the Orca CLI, and stop four settlements lying

A structured worker's provider child runs `orca orchestration ...` exactly like a PTY worker's
agent does, but it was handed the ambient PATH. On packaged Linux the CLI installs as `orca-ide`
so it never claims GNOME Orca's /usr/bin/orca (#7904), so bare `orca` execs the screen reader and
the worker can never read mail, reply or send worker_done; on packaged macOS/Windows the bundled
launcher is only reachable from the app's own resources dir. The PTY lane already solves this
inside `buildPtyHostEnv`; that block is now its own module and both lanes call it.

Also:
- a worker start that fails AFTER its session exists now discards the session, so a failed start
  stops stranding a dead chat tab that the durable restore index republishes on every launch;
- a structured worker's resource reconciles to `released` after settlement forgot its identity,
  instead of answering `unverifiable` for the life of the DB;
- `closeAttempted` is set only once a close is issued, so a tab-visibility failure can no longer
  report `closed_agent_terminal` for a running child;
- `forgetSession` prunes only what the settled worker parked, not every sibling whose target
  momentarily fails to resolve;
- release settles with an explicitly empty, warned archive when the journal is unreadable AND the
  session is proven exited — closing the chat tab is routine, and `archive_failed` there wedged
  release on evidence that could never arrive;
- the new migration test uses mkdtemp and cleans up, so it stops failing Windows CI and leaking.

* fix(orchestration): merge the duplicated release-receipts import

The release-completion module imported ./orchestration-worker-release-receipts
twice, which trips import/no-duplicates in audit:code-quality:native. The
changed-file gate does not load that config, so only whole-tree CI saw it.

* docs(runtime): note that a background structured tab re-publish is a no-op

The activate:false branch for an already-published session returns without
writing the snapshot or emitting, so it cannot re-surface a client whose
mirror lost the tab. Orchestration is safe from this only incidentally.

* feat(orchestration): make the worker mode the user's own default, not a flag

`worker-start --structured` was an explicit opt-in that REFUSED --on, --terminal,
--model/--effort and worktree-creating placements. The flag, its spec entry and the
`structured` RPC param are gone: the mode now follows the user's setting for new agent
tabs, so a local claude/codex worker is a structured chat session whenever the user's
own default says agent tabs open as one.

A setting is a preference, not a demand, so none of those combinations refuses any more.
A dispatch that cannot be structured starts an ordinary PTY terminal worker and the
receipt names the mode that ran and why, so the fallback is never silent:

- a remote --on, an existing --terminal, a new-child/new-top-level worktree and
  --model/--effort are decided from the request;
- the agent, TUI launch customization, Codex-on-Windows and the runtime capability are
  decided by the shared launch route;
- WSL, remoteness and the Windows start-time gate are settled by the executing host's own
  agentSession.createSupport, asked once the worktree resolves and before anything is
  created, so a refusal is a terminal worker rather than a failed start.

The decision is the renderer's, lifted rather than copied: `resolveAgentLaunchRoute`'s
structured half and the settings predicate now live in
shared/structured-native-chat-launch-route, which both surfaces call, and the TUI launch
customization test moves to shared beside it. `getClientSettings` gains the two native-chat
default booleans it was missing.

No security invariant moves: the structured worker registry, bearer handle, persisted pane
key, the absence of ORCA_PANE_KEY from the child env, hook attestation and lineage-derived
process incarnation are untouched.

* fix(orchestration): stop the worker mode leaking into the agent contract

The mode a worker runs in is a runtime implementation detail. An agent should be
taught the same verbs, run the same commands and read the same receipts whether it
is a structured chat session or a PTY terminal — otherwise a settings-driven
fallback silently changes what the agent can do.

The real leak was `canDispatchSubWorkers`, which was forced false for a structured
worker. That was not a wording choice: `worker-start` resolved `--from` through
`showTerminal`, which needs a live PTY or renderer leaf, so a `structworker_`
coordinator genuinely could not dispatch. Rather than withhold the capability, the
one fact the command needs from `--from` — its worktree id — now comes from
`getOrchestrationDispatchAuthority`, the same authority the pane-key and
process-incarnation getters already answer structured handles from. Sub-dispatch is
gated on depth alone, identically for both modes.

`showTerminal` itself is deliberately NOT taught structured handles: it returns a
ptyId, a leaf id and a pane runtime id, and synthesising those for a session with no
PTY would hand every caller of a public terminal verb something that looks writable
and is not. `inspectWorkerTerminal` already returns `terminal: null` for exactly
that reason.

Also neutralised three agent-visible refusals that named the worker's kind: a
`worker-read --source terminal` on a worker with no terminal now names the sources
that do work, and both archive refusals say "transcript output" rather than
"structured chat output" (the PTY `transcript_pin` branch said "structured" too).

New tests pin both properties: the two preambles are byte-identical once the handle
and per-dispatch ids are normalised, and a structured coordinator starts a worker
with `showTerminal` rejecting.

* fix(orchestration): stop claiming a structured worker was checked for a prompt

worker-show reported observation.agentWait: null for every structured worker. The
field's own contract says null means Orca looked and found no wait, and absent means
it never looked — and nothing looks here: a structured worker parks on a journal
question item, which no terminal prompt scan can see.

So null was a false negative on the one field a coordinator is explicitly told to
read, and it was mode-dependent: the same worker as a PTY would have reported the
wait. Absent is both the honest value and a state a PTY worker already reaches (an
older host, an unreadable pane, a probe that did not answer), so it discloses
nothing about which mode ran.

* docs(cli): stop the worker-start spec pointing a caller at the worker kind

The note said "the receipt mode field names the mode used and why", which is an
instruction to read a field no verb behaves differently for — the one thing the
mode was not supposed to become. It now says what a caller actually needs: the
dispatch always starts, the options passed are the ones honoured, and every worker
is driven the same way. The receipt still carries the mode for operators and
telemetry; nothing tells an agent to look at it.

* perf(orchestration): coalesce the structured redrive edge

Every journal batch is a redrive candidate, because a settled turn is tombstoned
rather than rewritten — there is no completed row to watch for. That is free while
nothing is parked on the session, but once mail IS parked each batch re-resolved the
dispatch, queried unread mail and read the host's gate facts, only to re-park because
the turn was still running. A turn streaming tool calls paid that per batch.

The edge now coalesces on a 300ms quiet window with a 2s starvation cap, so a
streaming turn costs a handful of evaluations instead of one per batch and a settled
turn still nudges promptly. Delivery semantics are untouched: the gate, the
accepted/rejected/unknown handling and the retain rules all still run exactly as
before, just fewer times. Nor is this the path fresh mail takes to an idle worker —
that is `deliverForHandle` at enqueue time, which this does not touch — so the
common case gains no latency.

The mechanism is the session.tabs notify coalescer, generalised into
`keyed-trailing-edge-coalescer` and called by both rather than duplicated; the
session.tabs windows stay where they were, since 50ms is right for a spinner title
and far too tight for a journal stream. Disposal drops the pending timer rather than
flushing it, on the existing subscription disposer that every settlement already
reaches, so a redrive can never fire for a session no dispatch owns.

* fix(orchestration): deliver direct peer mail to a structured worker, and let a peer read it

Two agent-to-agent verbs had no answer for a worker that IS a structured agent
session, and both failed quietly.

Mail addressed to a worker's own bearer handle — how agents mail each other
outside a dispatch — fell between the lanes. The send stored durably and
reported success, `getLiveTerminalPaneKey` resolved the recipient, and then
neither lane claimed the mailbox: the structured resolver answered only
`dispatch:` addresses, and the PTY lane refuses a structured handle outright.
Nothing errored and nothing logged, so the worker never reacted and the peer
waiting on a reply hung. The resolver now also answers a bare worker handle,
preferring that worker's active dispatch so peer and coordinator nudges share
one operation-ledger budget. A worker BETWEEN dispatches is still nudged, under
a session-scoped key: a dispatch says nothing about whether delivery is safe —
the idle gate and the lease fence do — and its own `check` reads exactly the
direct mailbox the mail is sitting in. The dispatch caller key is left
byte-identical, because the ledger is keyed on (callerKey, operationId) and
reshaping it would re-mint nudges already in flight as second turns.

`terminal read` had no structured branch, so the only peer-accessible read verb
answered `terminal_handle_stale` for a live worker; `worker-read` is closed to a
peer, which holds neither coordinator standing nor a dispatch id. It now serves
the session's journal, projected to LINES and paged by the same reader the PTY
tail uses, so the result stays a plain RuntimeTerminalRead and nothing an agent
reads discloses which kind of worker answered. Bounding and dispatch-capability
redaction are the archive path's, reused rather than rebuilt. A session that is
not attached refuses with the existing not-attached code rather than returning
an empty tail, which would read as "this worker has said nothing".

`terminal.show` still refuses a structured handle. This is read-only on purpose:
synthesising a ptyId/leafId/paneRuntimeId would hand every public terminal verb
something that looks writable and is not.

* fix(orchestration): stop three PTY-only probes answering for structured sessions

Three defects, one shape: a probe that enumerates PTYs or resolves a pane was
standing in for a question that is not about panes at all.

`worktree rm` destroyed a live structured worker. `killAllProcessesForWorktree`
sweeps the renderer graph, the provider session list and the local pty-registry,
and a structured session is registered on none of them — so all three counted
zero, nothing errored, and removal deleted the checkout out from under a running
provider child, which kept running with its `cwd` gone while the dispatch still
reported the worker live and exact. A fourth sweep now asks what the other three
cannot: membership by `location.workspaceId`, which covers a plain chat session
as well as a dispatched worker, and liveness by the same
`live`/`unverifiable`/`exited` observation the rest of the structured surface
uses. It REFUSES a destructive removal rather than auto-closing, on the same
bargain and the same `--force` escape hatch as the unstopped-PTY gate — this is
the verb that deletes a user's work, and a running agent is exactly what they
would want to be told about. Force closes the sessions properly instead of
orphaning a child. Best-effort reconciliation callers are excluded: they repair
state, delete nothing, and must never be failed closed.

Twelve coordinator verbs failed for a structured worker running as itself.
`isLiveTerminalHandle` validated `ORCA_TERMINAL_HANDLE` with `terminal.show`, a
PTY verb whose leaf lookup misses for a session that never had a pane; the pane
remint that would have recovered it needs `ORCA_PANE_KEY`, which a structured
child deliberately does not carry, so every one of them died on
`no_active_sender_terminal` — including the ones the worker's own dispatch
preamble tells it to run. The identity question gets its own probe,
`terminal.resolveIdentity`: a handle and a boolean and nothing writable.
`terminal.show` still refuses a structured handle, because synthesising
ptyId/leafId/paneRuntimeId would hand every public terminal verb something that
looks writable and is not. The PTY half is byte-for-byte today's check,
`getLiveLeafForHandle` included, so its `rendererGraphEpoch` re-check still runs
— that check is the whole reason the sender is validated at all, and a cheaper
probe would have quietly started passing stale post-reload handles. A host that
predates the method answers `method_not_found` and the client falls back to
`terminal.show`, which is correct for that host: one without the identity probe
has no structured workers to miss.

`dispatch --inject` reported `no_agent_detected` for a structured worker, because
`isTerminalRunningAgent` reaches `getLiveLeaf`, throws, and the catch returns
false. A structured session IS the agent; there is no foreground process to
recognise, so it answers before the PTY probes rather than through them.

Also: a Run whose coordinator is structured now gets its `run:` mail. Both lanes
declined and neither logged — the PTY lane because the owner is structured, the
structured lane because the mailbox was not `dispatch:` — so each half believed
the other owned it. The PTY lane's reasoning (a coordinator blocks in
`check --wait`, where a waiter preempts pointer delivery) does not transfer: a
structured coordinator is a chat session whose turn ends. Its `run:` deliveries
take the `hasOutstandingRunDelivery` gate the PTY lane applies for exactly that
mailbox, and only for that mailbox.

The test that would have caught the twelve drives the CLI with
`ORCA_TERMINAL_HANDLE=structworker_…` and no `--from`. Every existing
orchestration CLI test passes `--from` explicitly, so the resolver a real worker
goes through was never exercised — which is why the suite stayed green while the
preamble failed on its first line.

Two files crossed their line ceiling and are split rather than waived:
`worktree-teardown.ts` sheds its two PTY-surface sweeps and the deadline
arithmetic they share, and `orchestration.test.ts` — which sat exactly on 800 —
sheds the two caller-identity suites this change rewrote.

* fix(orchestration): arm the takeover signal for structured chat input

`worker-release` closed a structured session a user had taken over, losing work
mid-conversation, while `orchestration-worker-specs.ts:106` promised "Never
closes … user-taken-over terminals".

Every guard was already correct and simply never armed.
`reportWorkerTerminalUserInput` has exactly one call site — the real-user-input
signal on a PTY connection — so structured chat input never reached
`orchestration.workerTerminalUserInput`, `markWorkerTerminalUserOwned` never ran,
ownership stayed `owned` instead of `user_owned`, `retainedReason` never returned
`user_takeover`, and `stopStructuredWorker` proceeded. The durable flag is reused
as-is rather than given a parallel mechanism: it exists precisely so a restart,
an SSH drop or a renderer remount cannot erase a takeover.

Addressed by SESSION, never by pane key. A structured worker's pane key is a
random identity credential — anyone holding it can read and consume that worker's
mailbox, and session ids are embedded in tab ids in plain text — so it stays in
main and the runtime resolves the session to it. Handing it to a renderer to echo
back would make it learnable by anyone who can see a chat pane. The RPC gains an
optional `sessionId` alongside `paneKey`; a host that predates it rejects the
call, and the report is already best-effort with a catch, so that host degrades
to exactly today's behaviour rather than failing a send.

The signal fires from the composer send hook and only past `accepted`: the outbox
dispatcher retries, and orchestration's own pointer nudges never pass through the
composer at all — so neither can be mistaken for a user takeover.

* fix(orchestration): reach structured workers through group addresses

`orca orchestration send --to @all` — and `@idle`, `@claude`, `@codex`,
`@worktree:<id>` — silently skipped every structured worker. Recipients came
from `listTerminals`, which enumerates leaves and PTYs, and a structured session
is on neither. The exclusion happened BEFORE per-recipient resolution, so the
`SendRecipientWarning` machinery never ran: the caller got exit 0 and a receipt
naming the workers that did resolve, and a broadcast "stop work" or "base moved"
reached the PTY workers and nobody else. With every worker structured it
degraded to `terminal_not_found`, which reads as "the group was empty".

Fixed at the group-resolution site rather than inside `listTerminals`. That
result is published to paired mobile and remote clients and to consumers that
assume a summary carries a `ptyId` or is writable, so widening it is its own
change under `docs/reference/remote-wire-compatibility.md`. Group addressing
reads exactly three fields off a recipient, and `RuntimeTerminalSummary` already
satisfies them structurally, so the resolver widens to that smaller shape and
nothing here invents a `worktreePath` or a `branch`. Candidates are liveness-
gated on the same observation the rest of the structured surface uses — mail
addressed to a settled worker would be stored for a lane that will never deliver
it — and once a worker IS a candidate, the existing per-recipient warnings cover
it, so an unresolvable one is reported rather than dropped.

`@idle` needed more than enumeration: `getAgentStatusForHandle` reaches a PTY
probe that throws for a handle with no pane, so a structured worker would have
been enumerated and then silently dropped from the one group address that
selects on status. It now answers from the session's journal — and off the FULL
reduced timeline, never a bounded tail. Settlement tombstones the running turn's
lifecycle item rather than rewriting it, so on any page-sized read a long
tool-calling turn looks identical to an idle session; `@idle` would then
broadcast into a running turn, which Codex answers with `turn already running`
and Claude queues behind. An unreadable session answers null, never idle.

`terminal list` and `worktree ps` still omit structured workers; that is the
wire-visible half and is deliberately not in this change.

* fix(orchestration): refuse rather than guess when a chat session has no identity

An ordinary structured chat session — not a dispatched worker — is spawned with
no `ORCA_TERMINAL_HANDLE`, because `structuredWorkerChildIdentityEnv` early-
returns for any session outside the worker registry. `orca orchestration check`
then fell through to `terminal.resolveActive`, which picks the focused tab's
active leaf or the first leaf in the worktree. It returned a valid handle, so
nothing errored — and `check` is destructive by default, so it consumed another
pane's oldest unacknowledged batch and marked it read. The rightful worker never
saw that mail.

`requireUnambiguous` does not fix this, only narrows it: it refuses when MULTIPLE
leaves could be meant, and with exactly one terminal pane in the worktree the
guess still resolves — to a sibling. "One terminal pane plus one chat tab" is a
normal layout, so the common case stayed broken. The pinned test is that case.

So the child now carries `ORCA_STRUCTURED_SESSION`, and every remaining route
that would GUESS an implicit terminal refuses on it with an error naming the flag
to pass. The marker names NOTHING — no handle, no pane key, no session id, no
token — which is the whole reason it is safe: it cannot be replayed, cannot
impersonate, and cannot flow into the hook-attestation, agent-row or
mobile-projection pipelines the way a pane key would. That makes it a different
decision from withholding `ORCA_PANE_KEY`, not a reversal of it. It also grants
no CLI reachability, so packaged builds keep exactly today's exposure.

The comment at `orca-runtime-adopt-terminal-orphans-from-inventory.ts` that
justified the guess — "a structured worker is covered instead by the
`ORCA_TERMINAL_HANDLE` its child is spawned with" — was true only for dispatched
workers and false for every other structured session, a population this branch
creates. It now says which case it covers and which case it does not.

* fix(orchestration): stop two surfaces lying about a worker with no terminal

`orca terminal <verb>` answered `terminal_handle_stale` for a structured
worker's handle. Nothing went stale: the session is live and simply has no
terminal, and it never had one — so callers acted on a false claim and went
hunting for a remint that cannot exist. The refusal now carries its own code and
names the structured equivalents (`orca terminal read`, `worker-read --source
transcript`, `orca orchestration send`), so an agent that lands there learns
what to run rather than what failed. A PTY handle that really did go stale keeps
the old error, and so does a session this runtime no longer owns — that handle
IS dead. `terminal.show` stays non-resolving: synthesising a
ptyId/leafId/paneRuntimeId would hand every public terminal verb something that
looks writable and is not.

`orchestration-worker-specs.ts` promised "the same verbs, the same handle, and
the same worker-read sources", and all three clauses were false for a worker with
no terminal. A spec agents read must not carry a false promise, so it now states
the limitation and the alternative that always works.

Note this had to be reconciled with an invariant this branch already holds: the
worker MODE must stay opaque, or a coordinator starts branching on something no
verb it runs behaves differently for. So the note says "not every worker has a
terminal" and points at `--source auto`/`--source transcript` WITHOUT naming a
kind — the same mode-neutral wording `readStructuredWorkerOutput` already uses
when it refuses `--source terminal`. Both properties are now pinned by tests, so
neither can be restored by breaking the other.

* fix(orchestration): close the review findings on the structured parity work

Four defects and two follow-ups from the delta review.

The `worktree rm` refusal was a dead end in the desktop UI. Its message matched
no matcher in `classifyWorktreeForceDeleteReason`, and an ordinary desktop delete
already passes `force=true` for the dirty-file skip, so classification returned
null unconditionally: the toast showed raw CLI wording with no Force Delete
button, and a user with a live chat session was stuck unless they knew to reach
for the CLI. That is the #11960 shape `shared/worktree/removal.ts` documents, so
the refusal now has its own prefix, matcher, `WorktreeForceDeleteReason` and
toast copy, classified BEFORE the `force` guard and nulled once the waiver is
spent — exactly how `unstopped-pty` is handled, with matcher and hint kept in
the same file as that contract requires. The copy says Force Delete will close a
running conversation rather than borrowing the "could not confirm" wording,
because Orca watched these sessions stay attached; there is no doubt to waive.

Structured `terminal read` cursors were unsound and are now refused. The PTY
cursor indexes an append-only completed-line buffer with a monotone count; a
session journal is a BOUNDED tail re-projected on every read, so a saved index
addressed different lines as the journal grew — and `truncated` could never fire
to say so, because it tests `cursor < oldestCursor` and `oldestCursor` was always
0. A poller got wrong or duplicated lines under `truncated:false`. Separately, a
streaming turn's lines counted as completed with `partialLine` hardcoded empty,
so a mid-turn cursor consumed a half-written line whose growth was never
redelivered — the `"hel"`/`"hello"` hazard the PTY reader guards against. The
journal does have stable item identity, but `terminal.read`'s cursor is a number
on the wire and cannot carry it, so a cursor read now refuses and names
`worker-read --source transcript`, which already has that contract including
`source_changed`. No cursor space is advertised either: `nextCursor` is null and
the cursor fields are absent, rather than claiming an index the next read cannot
honour. The header claim that all four fields kept their meanings was true of the
shape and false of the invariants; it now says which ones hold.

Two fixes had no test at their real seam, which is the same failure that produced
this whole set — the runtime tested directly, the seam tested by neither. The
group-addressing test hand-composed the recipient list itself, so deleting the
composition at the call site left it green; it now drives `sendGroupMessage` with
no PTY terminals at all. Nothing referenced `isLiveStructuredAgent`, so the
`dispatch --inject` fix had no red-then-green at all; it now has one driving
`RuntimeTerminalAgentPresence.isRunning`. Both were ablated and confirmed red.

Folder-workspace removals sweep and kill PTYs without `requirePhysicalStop`, so
the structured sweep no-opped there and left a live session bound to a workspace
about to be forgotten. They now close best-effort under an explicit
`closeStructuredSessions` flag, kept separate from `requirePhysicalStop` because
the two questions differ: that one asks whether a stop must be PROVEN before
files are touched, and it is what licenses a refusal. These paths do not refuse —
the root is shared so no checkout vanishes under the child, and one of them is a
never-throw forget a refusal would wedge. Reconciliation sweeps set neither and
still close nothing.

Also: the force close is raced against the same sweep deadline every PTY surface
is bounded by, so a wedged provider close reports the timeout instead of hanging
`worktree rm --force` forever; and the refusal now prints a count and the
providers instead of raw session ids, which our own marker rationale treats as
one tab-id hop from a credential.

* test: pin structured-session close on the folder-workspace removal path

The folder and orphan removal callers now pass closeStructuredSessions so a
live structured session is closed best-effort rather than left bound to a
workspace Orca has forgotten. These three exact-args characterizations describe
that call and had not been updated.

* fix(orchestration): stop the structured worker-read cursor misdelivering silently

`worker-read --source transcript` for a structured worker fingerprinted only the
oldest item's id, so `source_changed` fired when the window slid off the front
and could NOT fire when the page's contents changed under a stable oldest item —
which is the normal case, because the journal is a reduced, mutable timeline. A
`running` tool item gains its `[tool result]` at its original sequence once later
items exist, the 60ms delta coalescer revises a message in place, settlement can
rewrite an item smaller, and a pending approval projects to null until it
resolves and then appears in the MIDDLE of the array.

Two silent failures followed, both returning ok. Omission: a caller handed a
coalesced `hel`, resuming past it, never received the revision to `hello world`
— the same defect we refused to ship on the terminal read path, already shipped
here. Duplication: a resolved approval inserted ahead of a saved index, which was
still accepted, so the caller re-read content it already had. The blast radius is
the coordinator polling loop, the verb's primary consumer.

The anchor is now the oldest item PLUS every item whose projected message sits
below the caller's position, by id and revision. `createWorkerOutputSourceIdentity`
already takes an arbitrary string array and the cursor is already opaque
base64url carrying its own position, so neither the wire shape nor the
`source_changed` contract changes.

Prefix-scoped rather than whole-page deliberately: fingerprinting every item on
the page would flip the identity every 60ms with the coalescer window during an
active turn, making the cursor unusable exactly while the worker is working —
that trades a silent bug for a useless verb. Tail growth the caller has not read
cannot invalidate; any change to what it already holds does. Position-dependence
is safe because `p` rides in the same opaque payload as the identity, and the
returned cursor is stamped with the identity of its own end, which is precisely
what the next read recomputes. The frozen archive keeps a constant identity: no
item can be revised under a caller there, so it has no prefix to fingerprint.

Both silent shapes are pinned across a page boundary with the journal mutating
between reads — a static-journal test passes either way. Two ablations at the
real call site: reverting to the oldest-item-only anchor turns both red, and
widening the prefix to the whole page turns the tail-growth case red, which is
what proves the scoping is real in both directions.

* docs(orchestration): stop the structured terminal-read refusal recommending a dead end

The refusal told a peer to "page it with `orca orchestration worker-read
--source transcript`", which is wrong three ways and this file said so itself:
its own header explains that this verb exists BECAUSE `worker-read` demands a
dispatch id and coordinator standing "a peer does not have" — and then the
refusal sent that same peer there. The verb it named is also a window index over
the same bounded page, so it is not a paging answer even for a caller who can
reach it; under load it now answers `source_changed` on most polls, which is
better than the silent hole it had before but still not what the sentence
promised.

The refusal now says what actually works — the tail is bounded and newest-last,
so poll it and diff — and names no alternative, because there is none. That is
the honest framing: a durable cursor is not achievable here at all, rather than
blocked on the wire shape. The journal is a reduced, MUTABLE timeline: an item's
projected text changes at its original sequence after later items exist, the
delta coalescer revises repeatedly, settlement can rewrite an item smaller, a
pending approval renders as nothing and then as something, and `sequence` resets
on epoch rollover. No index, numeric or opaque, survives that.

So the docstring's "pagination with a real anchor lives on `worker-read --source
transcript`" is gone too — there is no real anchor there — and the file now
records why no windowed alternative should be built later: a broken cursor fails
UNSAFE, as a silent hole in a poller's output, while diffing a bounded tail fails
safe as a harmless re-read, and a second paging-shaped verb would invite the PTY
assumptions this one cannot honour.

The test asserted the old advice, so it now pins the contract instead: the
refusal explains the working approach and must never name `worker-read`.
`worker-read --source transcript` remains a good bounded snapshot for a
coordinator reading a worker it dispatched; only the "or page it with" clause was
false.

* fix(i18n): add the missing worktree-removal agent-session refusal string

The structured-session removal refusal introduced a translate() key with no
en.json entry. Nothing local catches that: typecheck passes, and the full
suite passes, because a missing key falls back to its inline default at
runtime. Only verify:localization-catalog fails on it, which is why CI's
static analysis reddened on a branch that was green everywhere else.

Fallback wording mirrors the sibling unstoppedPtyLive string, since the two
refusals differ only in what is still running and what Force Delete does to it.

* test(codex): expect the no-identity marker on an unregistered structured child

The refuse-rather-than-guess marker landed after these expectations were
written, and all three assert exact env equality on the unregistered path —
the one branch that now carries ORCA_STRUCTURED_SESSION. One of the two files
was added by this same branch, so this is a self-inflicted drift; the other
predates the branch and was broken by it.

The marker's presence is still pinned positively by
structured-worker-child-identity-env.test.ts and the CLI's
orchestration-structured-session-no-identity.test.ts, so relaxing these three
exact-equality checks loses no coverage of the security property.

* fix(orchestration): require exit evidence before settling structured close

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 20:24:21 -07:00
Jinwoo Hong 3160b54c69 feat: real background push notifications for the mobile app (#8129) (#18554)
* feat(cloud): add the mobile push gateway and its contract package (#8129)

A small open-source service that holds the APNs key and FCM credentials and
sends background push to paired phones on the desktop's behalf. Hosts
authenticate with a box challenge and HMAC proof on their pairing key, the
same shape the relay uses, so signed-in and accountless desktops share one
path. Tokens are stored; alert text is held only for the coalescing window.

The contract doc in docs/reference is the source of truth for every wire
shape. The interop test runs the real desktop answerer against a real
gateway-issued challenge so transcript drift fails in CI.

* feat(push): register phones and send background push from the desktop (#8129)

Adds the notifications.remote-push.v1 capability, the registerPush and
unregisterPush RPCs on the mobile allowlist, a gateway client with a cached
session and 401 re-auth, a durable unregister outbox, and a dispatcher that
offers every mobile notification to the gateway after the socket fan-out.
The dispatcher is fire-and-forget with one retry and drops registrations the
gateway reports dead.

Puts agentState on the mobile frame and fixes the #4375 wording so a working
agent is never announced as finished. The relay host-proof code moves onto a
shared envelope module with no behaviour change.

* feat(mobile): background push registration, receive, and settings (#8129)

Fetches the native APNs or FCM token, registers it with every paired host
that advertises the capability, and re-registers on token change. Foreground
pushes are suppressed inside handleNotification against the same seen set
the socket path uses, so nothing shows twice. Taps route by host fingerprint.
One Background notifications switch, off by default, with the disclaimer and
needs-input / finished sub-switches; hidden until a paired desktop is new
enough. Adds google-services.json and the expo-notifications plugin.

* chore(cloud): Terraform and deploy workflow for the push gateway (#8129)

Declares the Cloud Run service, runtime account, secrets, and orca_push
database behind push_gateway_enabled, true only in production. The deploy
workflow is gated like the relay's, deploys with no traffic, probes /ready
and a validate-only FCM send, then shifts traffic. It runs as the shared
production deploy account because the Cloud SQL rollout lease grant is
foundation-owned; its extra authority is three bindings on the push service.
docs/push-gateway.md carries the import commands for the resources created
by hand and the APNs key rotation procedure.

* docs: describe background notifications on the phone (#8129)

* docs: check in the mobile push contract (#8129)

Seven committed files cite it as the source of truth for every wire shape;
docs/reference is allowlisted per file, so add the entry.

* test(push): replay one checked-in host-proof vector on both sides (#8129)

Cloud Verify installs only the cloud workspace, so the gateway suite cannot
import the desktop answerer. Replace the cross-workspace import with a fixed
challenge vector generated from the contract package; the gateway fixture and
the desktop answerer each replay it and must produce the same HMAC. A
transcript drift on either side now fails in that side's own suite.

* fix(cloud): open the push gateway with invoker_iam_disabled, not an allUsers binding (#8129)

The production domain-restricted-sharing policy rejects an allUsers
run.invoker member, which the runbook anticipated. Opt the service out of
invoker IAM the way the relay director already does; the host proof is the
authentication either way.

* docs(cloud): the push.onorca.dev record exists and is hand-managed (#8129)

* fix(push): close review findings in the gateway (#8129)

- Quota reservation takes a per-host advisory lock; READ COMMITTED admitted
  a whole burst past the cap (80/80 without, 60/80 with, against Postgres 16).
- Challenge issuance no longer writes push_hosts; the row lands on proof
  verification. Stale hosts prune after 30 days. Per-IP token bucket on the
  two unauthenticated routes.
- Streaming body limit via hono bodyLimit; a chunked body bypassed the
  Content-Length check.
- registrationIds deduped in the schema; per-host device cap of 64; list
  bounded to its schema.
- Gateway-side challenge TTL is the specified 10 s, not 40 s.
- APNs stream settles on close as well as end/error.

* fix(push): close review findings in the desktop client (#8129)

- A gateway registration the registry cannot persist is enqueued for delete
  instead of leaking a live token.
- Unregister outbox re-reads pending per pass, honours enqueues during a
  drain, and retries with backoff instead of waiting for the next launch.
- Dispatcher batches registrations by 20 rather than starving the rest.
- 401 compare-and-clear; a 401 after re-auth is unreachable; refused
  handshakes and 429s are cached briefly instead of re-handshaking per event.
- Service is stopped on quit.

* fix(mobile): close review findings in push registration and receive (#8129)

- Consent generation guards a register that finishes after the switch went
  off; the host is re-queued for unregister instead of recorded live.
- Foreground pushes seed the watermark before adopting the epoch, so a push
  on a never-connected session cannot wipe a valid watermark.
- aps-environment follows the build via app.config.js; the iOS release
  workflow sets it to production. A bare plugin entry wrote development.
- Pushes the OS showed while closed are marked seen before catch-up replay.
- Token null result is not cached; failed capability probes are retried and
  never block an unregister; coalesced summaries are shown but not marked.
- Unresolvable fingerprint routes nowhere and is suppressed in foreground.
- Android channel ensured at boot; capability hook diffs clients by identity.

* fix(cloud): harden the push deploy workflow and size the gateway to the budget (#8129)

- Roll traffic back on a failed post-shift check; delete a candidate that
  never took traffic; retry the origin probe and the FCM probe.
- Assert Terraform-owned scaling instead of mutating it from the workflow.
- Build before taking the Cloud SQL rollout lease.
- Declare the database pool in Terraform (2 per instance, max 2 instances)
  and add the gateway to the connection budget; the previous default put the
  shared instance 65 connections over its ceiling.
- State plainly that the shared deploy identity's relay authority is inherited.

* fix(push): read the runtime from shared state at push startup (#8129)

Threading the runtime through launchDesktopMode put the launch module one
line over the 300-line lint budget after the rebase.

* fix(push): key the unauthenticated rate limit on the hop Cloud Run wrote (#8129)

Cloud Run appends the connecting peer to x-forwarded-for; the limiter read
the left-most value, which the caller controls, so a forged first hop earned
a fresh bucket per request.

* fix(push): close the final security review findings in the gateway and infra (#8129)

- app.onError logs only the error name and answers a bare 500; hono's default
  handler printed the whole error, and a pg error carries the row in detail
- a second per-IP bucket (240/min) runs ahead of the bearer lookup on every
  authenticated route, so forged bearers cannot spend the two-connection pool
- one live session per host: minting deletes the host's earlier row
- device-less hosts are pruned after 1 h, not 30 d; any keypair mints one free
- notificationId is printable ASCII, since it becomes the APNs collapse header
- the impersonated FCM probe token is masked in the workflow log
- prevent_destroy on the Apple secrets and the orca_push database

* fix(push): close the final security review findings in the desktop client (#8129)

- fetch never follows a redirect: a 307 would replay the host proof and the
  phone's token to whatever origin the redirect named
- registerPush params are strict and the paired identity is spread last
- a per-device bucket (10/min) bounds a phone looping registerPush, which
  costs a gateway write and a synchronous registry write each time

* fix(mobile): close the final security review findings in push receive (#8129)

- a push with no epoch can no longer claim a seq-derived dedup key, in the
  foreground or from the tray; a forged seq:N could otherwise swallow the
  real bell at that seq
- a provider-delivered push with no host catalog, or no fingerprint at all,
  stays unrouted instead of falling back to the hostId its raw data carries

* docs(push): record the ip buckets, session and host retention, and the token-ownership limit (#8129)

* fix(push): apply the schema on an untimed pool and retry statement-timeout aborts (#8129)

Ports the relay's #18722 pattern to the gateway: DDL runs on a one-connection
pool with statement_timeout 0 that is closed before the serving pool opens, and
SQLSTATE 57014 joins the bounded transaction retry path.

* fix: harden mobile push delivery and deployment recovery

* feat: align mobile notification preferences with desktop delivery

* fix: accept variable-length APNs device tokens

* fix: deduplicate native APNs and background socket notifications
2026-09-06 23:16:29 -04:00
weekbinandNeil e48d83a5e1 Fix MiniMax China usage routing and credential handling (#14929)
* feat(minimax): endpoint selector, API key auth, weekly usage window (#14264)

The MiniMax (MiniMax) Coding Plan usage fetch was hardcoded to the
overseas platform (platform.minimax.io) and a single 5h session
window, so users on the CN endpoint (www.minimaxi.com) got nothing.

Three changes:

- Add `minimaxEndpoint` (`overseas`|`cn`) and
  `minimaxApiKeyConfigured` settings fields with sensible defaults
  that preserve current behavior. The CN endpoint also accepts an
  API key (safeStorage-encrypted via a new
  `minimax-api-key-store.ts` + IPC pair) for users without a
  browser session cookie. Status-bar visibility now OR's both
  credential flags.
- Cookie-jar origin now tracks the active endpoint. Previously
  cookies were stored under the overseas origin and silently
  dropped when the user picked CN — fixed by threading
  `endpointMode` through the request context, the manual cookie
  header path, and the cookie-jar clear.
- Parse the weekly window in addition to the 5h session and
  surface both as per-window chips (`5h [bar] 10%   wk [bar] 20%`).
  The status bar's compact section prefers the session window; the
  popover keeps the existing `Session` / `Weekly` labels. The
  MiniMax fetcher is split into three files (data / parse / main)
  to stay under the 300-line cap.

i18n is scoped to the Settings-page text (en + zh only); the 5H/7D
duration shorthands stay English across locales by project convention.

Tests: 9 new/updated files; cookies + API key exercised end-to-end
via the rate-limit service with the upstream-refactored test files
(`service-minimax-usage.test.ts`,
`web-preload-api-settings.test.ts`,
`web-preload-api-agent-providers.test.ts`,
`service-test-harness.ts`, and the runtime-home / reset-credit
fixtures).

Refs #14264

* Keep merge formatting scoped to MiniMax

* Keep MiniMax credential status in rate-limit test fixtures

* Use the China console origin for MiniMax request referer

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-06 19:20:50 -07:00
Neil e7563c63f1 fix: fence browser recovery to attach inventory placements (#18910)
* fix: fence browser recovery to attach inventory placements

* refactor: name the attach-inventory fence and make its test deterministic

Extract the placement check into isPlacedAsObservedAtAttach so the recovery
filter stays a flat list of named predicates, and document that omitting
pagePlacementsAtAttach recovers against unfenced live state.

Replace the 30-microtask drain in the post-attach regression with the handler's
own completion: attach only settles after recovery returns, so awaiting the
dispatch orders the assertions instead of guessing at a microtask count.

Verified by forcing the fence open: both regressions fail (the post-attach one
in 60ms on a retired placement) and the other 27 still pass.

* test: settle the attach handler even when the regression fails early

The barrier ran inline, so a waitFor timeout or the placement guard left the
attach handler parked on a promise nothing awaited. Hoist it into settleAttach
and call it from a finally as well; cleanup is guarded and the dispatch promise
is already settled, so the second call is a no-op.
2026-09-06 18:50:15 -07:00
Neil f88cbb4fc9 fix: keep paired tab updates live after runtime terminal fallback (#19022)
* fix: preserve session publication during runtime terminal fallback

* refactor(runtime): align fallback epoch comment and test preamble

Match the file's `// Why:` comment convention on the inherited
publication epoch, and drop a redundant duplicate mocks import in the
lineage regression test while keeping the required side-effect order.

No behavior change.
2026-09-06 18:29:28 -07:00
Neil b497f15b53 fix: preserve renderer browser publication during client-hosted page updates (#18961)
* fix: preserve renderer browser publication during client-hosted page updates

* refactor: drop the now-dead publicationEpoch selection argument

applyBrowserSessionTabSelection took a publicationEpoch and wrote it over
the epoch the spread snapshot already carried. Its only production caller
now passes snapshot.publicationEpoch, so the parameter is a no-op whose
only remaining power is to reintroduce the epoch rotation this PR fixes.

Remove it, and collapse the repeated prototype-cast boilerplate in the new
reconciliation test into one helper.

No behavior change.

* fix: keep reconcile from publishing a browser row twice

The retention filter partitioned existing rows by placement kind, so its
disjointness from the live build relied on a non-local invariant: that the
page registry only ever stores client placements and that server tabs are
empty while no offscreen backend exists. Drop ids the live build already
published instead, so a duplicate row is impossible by construction rather
than by coincidence.

* fix: stop the browser reconcile republishing on a pure reordering

headlessBrowserTabsUnchanged compares by array index, so rebuilding the live
list renderer-first read an interleaved snapshot as changed and republished
with a bumped version and rebuilt tab groups for no semantic change - the
same churn this branch exists to remove.

Key the live set by id and emit it in the order the snapshot already had.
Keying also makes uniqueness unconditional rather than resting on the page
registry only ever storing client placements.
2026-09-06 18:29:22 -07:00
Neil 4ba8ddce48 fix: prefer retained provider snapshots during hidden terminal recovery (#18972) 2026-09-06 18:29:03 -07:00
Brennan BensonandMerge Sim c49345d358 Fix native chat completion sorting and restored activity timestamps (#19144)
* Fix structured native chat completion sorting and timestamps

* Preserve native chat activity across settled updates and host upgrades

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 17:41:56 -07:00
Brennan BensonandMerge Sim b7b6ea3942 fix(native-chat): auto-rename the workspace on a structured chat's first turn (#19138)
* fix(native-chat): auto-rename the workspace on a structured chat's first turn

Structured native chat (Claude and Codex) never reached the first-work
workspace rename. The orchestrator has a single production caller, the
agent-hook server listener, and structured sessions never set
ORCA_PANE_KEY, so no hook event could ever be attributed to one. The
renderer knew this and suppressed pendingFirstAgentMessageRename for
structured launches at three sites, which also closed the gate the
folder-workspace title rename depends on.

The host's status feed already computes the exact edge: status 'working'
with a latestPrompt normalized the same way the hook payload is, and a
workspaceId that IS the worktree id. Publish that projection to the host,
thread it out to the runtime, and hand it to the same orchestrator the
hook path uses.

Re-projections of state the host already knew (restore, an arriving
subscriber) are flagged as replays and map to the orchestrator's existing
isReplay gate, so a host restart cannot rename off a stale journal.

One host and one journal serve both providers, so this covers Claude and
Codex together.

Verified in a live Electron instance, worktrees created through the real
composer and prompts sent through the real chat composer:
  Codex  langouste -> retry-helper-exponential-backoff
  Claude prowfish  -> parse-csv-headers

* fix(native-chat): preserve first-work rename across runtime and queued turns

* fix(native-chat): skip branch rename for folder projects

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 17:38:06 -07:00
Brennan BensonandMerge Sim ad4dc353f3 fix(native-chat): settle a structured send the provider proves it received after the ack window (#19140)
* fix(native-chat): settle a structured send the provider proves it received after the ack window

A send waits a bounded window for the provider to echo the message it was given.
On timeout the dispatch resolves `unknown`. The echo that arrives later IS matched
— `recoverLateIdentity` uses it to repair the session's turn identity — but nothing
tells the journal, and `unknown` is terminal there. The submission stays unknown for
the life of the session.

Two consequences, both reachable on any ordinary session:

- The composer renders "Message delivery is unconfirmed." with a Retry, forever,
  for a message that was delivered and answered.
- Retry redispatches, because the host only replays a recorded outcome unless
  `retryUnknown` is set, which that button is the only thing that sets. So the
  banner is a duplicate delivery armed and waiting for a click — and a user who
  believes the banner and resends is doing exactly that by hand.

Every send made while a turn is already running takes this path: the provider does
not echo a queued message until the running turn ends, which is far past the 10s
ack window. Sends made while idle are unaffected, which is why this reads as
intermittent.

Carry the `clientMessageId` on the dispatch waiter and settle the journal
submission `accepted` when the late echo proves delivery. Deliberately unfenced
against the dispatch sequence: that fence decides which turn owns the identity,
while delivery is settled either way. Already-terminal rows are untouched.

* fix(native-chat): persist late dispatch receipts before session close

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 16:41:09 -07:00
Jinwoo Hong 59fe8266bd fix(orchestration): keep worker lineage across app restart (STA-6366) (#19121)
* fix(orchestration): keep worker lineage across app restart (STA-6366)

Terminal handles are minted per process, so after a restart the projected
parent (coordinator or creator) named a handle no live row carried and every
worker rendered as a top-level row.

The projection now resolves the parent from the durable pane keys
(runs.coordinator_pane_key, tasks.created_by_pane_key) whenever the stored
handle is not one this process minted, re-resolves it to the live handle for
that pane, and omits stale handles so they cannot mismatch a row. The
creator-pane incarnation gate is untouched: it still decides mutation
authority, and display lineage no longer depends on it. Dispatch lookup also
passes the pane identity so a worker's own dispatch resolves once its handle
is reminted.

* test(orchestration): compare lineage without the merged attention field
2026-09-06 16:53:35 -04:00
Brennan BensonandMerge Sim d07c47593d feat(mobile): structured native Claude chat (#18741)
* feat(mobile): structured native Claude chat

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

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

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

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

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

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

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

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

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

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

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

* fix(mobile): harden grouped Claude question answers

* fix(mobile): retry transient structured support probes

* fix(mobile): preserve grouped prompt response compatibility

* fix(mobile): preserve tokenless duplicate choice identity

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

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

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

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

---------

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

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

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

## ELI5

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

## What changed

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

## Why

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

## Linked issues

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

## Review record

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

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

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

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

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

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

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

## Testing

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

## Notes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: remove a scratch repro file committed by mistake

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Each of the three variants now fails a different test.

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

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

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

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

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

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Merge Sim <sim@local>
2026-09-06 00:50:20 -07:00
Neil 15dabf8d0b perf(worktree): overlap base refresh with prepared checkout (#18998)
* Stop obsolete worktree preparations when evicted or expired

* Let worktree preparation proceed during stale reclamation

* Verify creation during stalled stale worktree reclamation

* Preserve preparation ownership until Git removal starts

* test: keep artifact share fixtures unexpired across calendar dates (#18955)

* perf(worktree): overlap base refresh with prepared checkout
2026-09-05 22:42:06 -07:00
Neil 64a449df4e perf(search): assemble fragmented subprocess lines incrementally (#18973) 2026-09-05 21:56:15 -07:00
OrcaWinandOrca Worker fc5fa16870 perf(windows): split the process table into two flag sets (#17866)
* perf(windows): split the process table into two flag sets

MDE flags "suspicious memory activity" on the process-table reader: it
opened a handle into every process on the box and read each one's PEB on
a repeating cadence. Two changes narrow that.

Drop `Memory` outright. It cost a second
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) plus
GetProcessMemoryInfo per process, and nothing reads a working set off
this table -- the Resource Manager runs its own sweep, and the addon
stores WorkingSetSize into a DWORD so anything above 4 GB wraps.

Split the rest in two. `readWindowsProcessIdentityTable[Fresh]` is a
bare Toolhelp32 walk with zero per-process handles, and returns
`WindowsProcessIdentityRow`, which has no `command` to read.
`readWindowsProcessTable[Fresh]` keeps the command line for the callers
that match on it. PTY root identity and the owner start-time probe move
to the cheap reader; agent recognition, port attribution, codex turn
processes and structured-TUI matching all genuinely need the command
line and stay.

Two independently single-flighted caches, never one per caller: the
fan-out this module prevents is one scan per caller, and each reader
still serves every caller wanting its flag set. The wedge gate and the
3s deadline stay shared, because both readers call the same addon and
one wedged read latches its one `requestInProgress`. With no binding
there is only the 1.4s PowerShell scan to run, so the identity view
rides the detailed snapshot rather than forking a second one.

Measured on Windows 11, 492 processes (p50/p95): identity 6.3/7.0 ms,
detailed 12.3/13.4 ms, previous memory+commandLine 13.1/14.1 ms.

* fix(windows): serialize native process-table reads across flag sets

The two flag-set readers could both be in flight at once, and the
vendored wrapper does not tolerate that. `getRawProcessList` pushes the
callback onto one list and calls the addon only when no request is in
progress, so a second concurrent caller's `flags` are DISCARDED and it
is handed the first caller's rows. Measured against the real addon:
identity issued first, both callers got the same array, 0 of 541 rows
with a command line. A detailed read overlapping an identity read
therefore returned a table with every command line empty, which agent
recognition reads as "no agent" -- silently, and only under concurrency.

Nothing already here excluded that. Each snapshot cache single-flights
only within itself, and the wedge set latches only after a read misses
its 3s deadline, so through the healthy ~12ms of a scan neither reader
excluded the other. Overlap is the normal state: panes poll detailed at
750ms while a teardown takes identity snapshots.

`nativeReadGate` admits one native read at a time across both flag sets.
It also fixes the relay path, where `adaptAddon` has no queue at all and
two simultaneous CreateToolhelp32Snapshot calls are the crash the
vendor's queue exists to prevent. Every link settles, so a wedged read
never strands a waiter; the waiter re-checks the wedge and rejects. With
one call outstanding, retention stays bounded at one callback rather
than one per reader.

Also from review:

- The CIM fallback now belongs to the detailed flag set alone, and the
  identity view projects that snapshot through `toIdentityRow`, so an
  identity row carries no command line on a no-binding host either.
- The concurrency test modelled the wrapper's coalescing queue, which
  the previous synchronous mock could not express; verified failing
  without the gate and passing with it.
- `agent-session-process-identity-probe` early-returns when the
  creation-time flag is unavailable, which no shipped addon build
  provides, instead of scanning the table to produce null.
- Corrected the cost framing: Memory took an OpenProcess(...|VM_READ)
  it never read through, so dropping it halves per-process handle opens
  and leaves the PEB/ReadProcessMemory telemetry unchanged.

* test(windows): keep read exclusion across resets and flag each field

Two review follow-ups, both about tests passing for the wrong reason.

`resetNativeReaderState` replaced the read gate with a resolved promise,
so waiters still holding the old chain ran beside reads queued on the
new one. Reachable only from the `__set*ForTests` hooks, which is what
makes it worth fixing: it hands a suite two concurrent calls into its
own mock addon -- the exact condition the concurrency tests exist to
detect. Chain onto the gate instead; every link settles within the
deadline, so the bounded wait that costs is the right trade.

The coalescing mock shaped every field off the CommandLine bit, so an
identity read that did request CreationTime got `creationTimeMs`
stripped. The identity-side assertion was then only `!('command' in
row)`, which a correctly flagged read and a coalesced one satisfy
equally: a future regression losing identity flags under concurrency
would have kept the case green. Gate each field on its own bit and
assert `creationTimeMs` positively, inside the helper both orderings
share.

Concurrency assertions move to a new bare-addon mock. The coalescing
mock's own latch means it can never report more than one call in
flight, so measuring exclusion there proved nothing; the bare addon has
no queue -- like `adaptAddon` on a relay, where re-entering
CreateToolhelp32Snapshot is a real crash -- and makes re-entry visible.

Verified by deletion: restoring `nativeReadGate = Promise.resolve()`
fails the reset case with `expected 2 to be 1`, and restoring the
single-bit mock fails both overlap orderings on `creationTimeMs`.

* docs(windows): count the third test defect in the list that names them

The section opened "Two defects have now shipped", numbered two, then
described the third in its closing paragraph -- a list that reads as a
complete account while quietly omitting one, which is the exact failure
the section exists to warn about. Say three and number it, and note that
the third arrived inside the fix for the first two.

Also record why the creationTimeMs and flags-array assertions are not
redundant, in the doc and beside the assertions: the flags array catches
a read served another flag set's rows, the positional creationTimeMs
check catches field shaping (identity dropping CreationTime, or
toIdentityRow not forwarding it). Neither sees the other's failure.

* docs(windows): stop describing a PEB read this release removed

Every comment here that justified the flag split in terms of PEB reads became
false when the command-line reader moved to the kernel. Left alone, the
enumeration doc contradicted itself inside one file: the flag-set section
described three chained `ReadProcessMemory` calls per process while the
sections below it explained that the addon contains no such primitive and has
no PEB fallback.

The measurement is now attributed rather than merged. Dropping `Memory` halved
the per-process handle opens and nothing else -- both handles carried
`PROCESS_VM_READ` at the time -- and it was replacing the PEB walk that took
`PROCESS_VM_READ` and `ReadProcessMemory` out of the addon. Neither change
substitutes for the other, which is worth keeping straight: the split's
remaining value is the handle itself, not the memory access.

Also adds `relay/windows-port-scan.ts` to the caller table, the one caller this
effort introduced, and records that it reads only pid/name through the detailed
reader -- free while a pane is polling, not free on a headless relay.

* test(windows): pin the fresh links path against the identity TTL cache

The identity and detailed tables are separate snapshot readers with
independent TTLs, so the detailed path's existing freshness guard says
nothing about the ancestry walk's. Cover the identity reader on its own.

---------

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

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

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

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

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

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

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

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

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

Two further self-inflicted problems, both measured:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No assertion is relaxed -- the counts stay exact.

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-05 21:13:06 -07:00
OrcaWinandOrca Worker cff202c16a fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell (#17880)
* fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:12:40 -07:00
Neil 4e8e14424d perf: avoid splitting every path during file autocomplete (#18919) 2026-09-05 20:03:08 -07:00
Jinwoo Hong 54a8afc91d fix(orchestration): typed error codes for dispatch and worker-start refusals (#18902)
* fix(orchestration): typed error codes for dispatch and worker-start refusals

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

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

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

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

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

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

Review on #18902:

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

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

Codex review of #18902:

- Every call site keeps the exact message it published on main
  ("Task not found: <id>", "only a ready Task can start.", "cannot retry
  from Dispatch"); the shared contract now takes the message per site and
  only owns the code and data. Baseline strings are pinned as literals.
- createDispatchContext's own missing/non-ready guards, including the
  atomic-claim loser, now emit the same typed receipt instead of a bare
  Error, so a dispatch that races a status change no longer flattens to
  runtime_error. Covered by a dispatcher-level race test.
- Invalid --retry-of keeps task_not_startable but now carries status,
  unmetDependencies, retryOf, and a retry-specific next step.
- Dependency recovery text distinguishes waiting on running deps from
  retrying/unblocking failed ones.
- CLI test adds an unknown-code case so the old-client claim rests on an
  assertion, not a comment; SSH test asserts exact stdout.
- Guide table narrowed to the covered preflight cases; occupancy stays
  runtime_error and is named as such.
2026-09-05 20:27:29 -04:00
Brennan BensonandMerge Sim f8780a2c86 feat(native-chat): stop monitored tasks individually (#18807)
* feat(native-chat): stop monitored tasks individually

* test: expect Claude task stop capability

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* test(worktree): cover optimized creation call signatures

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

* perf: preserve user Git checkout worker settings

* perf(git): skip malformed remote base probes

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

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

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

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

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

* test: allow either observed self-exit snapshot ordering

* test: capture frozen-host input recovery evidence
2026-09-05 15:08:22 -07:00