Commit Graph
73 Commits
Author SHA1 Message Date
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
Neil f37d2fec97 fix(linux): land the reviewed Linux packaging stack on main (#18100)
* fix(linux): give the CLI one entrypoint by extracting the AppImage once

* refactor(linux): trim AppImage CLI registration seams

* test(cli): assert registration lock serialization

* fix(linux): fence AppImage terminal shim mounts

* fix(linux): accept extracted AppImage runtimes with APPDIR only

* docs(linux): make headless AppImage extraction runnable

* refactor(linux): import bundled launcher directly

* fix(linux): reclaim superseded AppImage payloads and packaged symlinks

Pruning removed 3215 of 3216 files from a superseded generation and always
stranded resources/app.asar, leaking ~105 MB per version update. Electron's
asar shim reports a *.asar file as a directory, so the recursive remove tried
to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it.
Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and
519M again once the payload is actually reclaimed.

removeExtractedAppImagePayload holds process.noAsar for the removal, counted
so overlapping removals cannot hand the shim back early, and the prune site
now warns with the path instead of swallowing the rejection. All three
removal sites use it -- staging cleanup and displaced roots leaked the same
way.

Also reclaim symlinks left by a packaged deb/rpm install, which the
extracted-cache-only rule turned into a hard conflict on a deb -> AppImage
migration, and name the remedy in the conflict error.

* fix(linux): bound the CLI registration lock wait

`retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per
attempt an IPC-driven registration could hang ~16 minutes against a wedged
holder with no feedback.

A legitimate holder is bounded by the extraction timeout, so wait that plus
slack and then fail with a message naming the lock file, rather than hanging.
`maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile.

* fix(linux): stop re-extracting the AppImage on inode metadata churn

The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime.
ctime moves on any inode metadata write -- `chmod +x`, which every AppImage
user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup
restore -- none of which alter a byte of the payload.

Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime
identical and moves ctime alone, so the key changed and the next launch paid
a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it
already had, then pruned the old generation.

Key on content identity instead. An in-place content change moves mtime and
almost always size; a replacement moves the inode. The existing
replace-in-place test still passes.

* fix(linux): stop CLI commands from falling through to Chromium startup

* refactor(cli): remove redundant command membership check

* test(cli): cover command-named project selectors

* fix(cli): redirect the open-url command before startup

* test(linux): cover AUR serve wrapper flags

* fix(linux): tighten CLI launch detection

* fix(linux): respect CLI flag value boundaries

* fix(linux): strip injected Chromium switches from CLI args

* fix(linux): report a missing display instead of dying in uv_close

* refactor(linux): read display locks without a preflight race

* fix(linux): preserve unverified external displays

* chore: format reliability gate manifest

* test(packaging): split runtime resource checks

* fix(linux): fail serve when no display is available

* fix(linux): do not treat a lockless X socket as a dead display

An X server writes its lock beside its socket and both survive a crash
(verified against Xvfb under SIGKILL), so a socket with no lock was never
left by a crashed server. It is an endpoint published from elsewhere: a
container bind-mounting only /tmp/.X11-unix, WSLg, or a foreign PID
namespace. Declaring those dead made the desktop gate exit(1) on displays
that work, with no workaround, and the serve gate refuse to start.

Liveness now splits by ownership. A foreign DISPLAY trusts a lockless
socket; Orca's own :99 does not, because removeStaleDisplayArtifacts
unlinks the lock before the socket and so manufactures that state itself --
adopting it would resurrect the orphan-socket bug and stop the cleanup from
self-healing. The stale-lock rejection is unchanged.

Also correct four doc statements this behaviour falsified.

* fix(linux): fail closed when a stale socket blocks the Xvfb rebind

Readiness only checked that /tmp/.X11-unix/X99 exists. A stale socket we
could not unlink still exists after our own Xvfb refused to bind, so Orca set
DISPLAY to a dead server and Chromium died in Ozone init.

Measured on Ubuntu 24.04 against the pre-fix build: with a leftover :99
socket and no lock, serve exits 139 (SIGSEGV), the socket inode is unchanged
before and after, and no lock is recreated -- it neither cleaned up nor
respawned. To a user that is a crash, not a misconfiguration.

This is reachable in the documented topology, where orca-xvfb.service has no
User= and runs as root while serve runs as User=orca: /tmp is sticky, so the
orca uid cannot unlink a root-owned socket, rmSync fails, and Xvfb exits with
the display already active.

Readiness now requires the display to actually be live -- our socket plus a
lock naming a running process -- so the same state reports an unusable
display and exits 1 with the existing diagnosis.

* fix(linux): recognise abstract X sockets and inherited Wayland fds

Two display setups this gate could not prove were refused outright, and on the
desktop path that is app.exit(1) with no workaround.

An X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which
leaves no filesystem socket to stat. Abstract addresses are kernel-owned and
vanish the moment the owner exits, so an entry in /proc/net/unix is proof of a
live server -- no lock file needed and no stale entry possible. Verified on
Ubuntu 24.04, where 139 such addresses were present.

WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so
there is no path to stat and WAYLAND_DISPLAY may be unset entirely. Its
presence is the display.

Both are consulted only after the filesystem-socket check fails, so no
existing verdict changes.

* fix(linux): never treat Orca's own display number as a foreign endpoint

Recognising a lockless X socket as live is correct for an endpoint published
from elsewhere -- a container bind mount, WSLg -- because an X server writes
its lock beside its socket and both survive a crash. It is wrong for
VIRTUAL_DISPLAY_NUMBER, because Orca's own teardown unlinks the lock before
the socket and so manufactures that exact state.

The managed branch was already strict, but a caller that sets DISPLAY=:99
explicitly takes the foreign path and skipped it, accepting a dead display
left by Orca's own interrupted cleanup. Route the managed number through the
strict probe on both paths.

Found by an adversarial audit of the asymmetry introduced earlier in this
branch; the documented systemd topology is unaffected because its Xvfb writes
a real lock.

* test(linux): add a packaged-artifact contract for the CLI launch paths

* test(linux): avoid buffered serve readiness detection

* test(linux): signal AppImage serve owner directly

* test(linux): tolerate readiness timeout boundary

* test(linux): add startup margin to shutdown oracle

* ci(linux): give package contracts timeout headroom

* fix(ci): route all Linux packaging contract changes

* test(linux): poll shutdown readiness without tail leaks

* test(linux): bound shutdown cleanup grace

* test(linux): assert on CLI output, not the harness's own control lines

run-cli-case.sh echoes `RESULT status=N case=<name>`, and the two cases named
*-skills asserted `expectOutput: 'skills'`. That substring was satisfied by
the case name in the harness's own line, so 2 of 8 cases asserted nothing
about the command -- gutting `skills` entirely would still have gone green.

Control lines are now excluded before matching, and both cases assert the
rendered help header, which only real help output produces. Verified on an
Ubuntu 24.04 host: 8/8 still pass against a stack-tip AppImage.

Also register the gate in reliability-gates.jsonc, which #15085 added a CI
Docker gate without. Red/green is recorded from a stock release AppImage
failing 4 of 8, three of them at status 133 (SIGTRAP).

* fix(linux): require static AppImage runtimes (#17319)

* test(linux): reject a wrong-architecture native binary at packaging time

Cross-building the arm64 slice on an x64 host silently packed an x86-64
`pty.node` -- the rebuild logged "Forcing native rebuild for linux-arm64" and
shipped the host's binary anyway. Every gate here inspects symbol versions,
which are perfectly valid on the wrong architecture, so nothing noticed.

Observed on a Raspberry Pi 5: the packaged app loaded, then failed with
"Failed to load native module: pty.node", and the launch contract reported
3 of 8 cases crashed rather than naming the cause. Swapping in the aarch64
`pty.node` took the same build to 8/8.

Compare ELF `e_machine` against the slice being packaged and fail with the
offending path. Checked before the glibc pass, because a wrong-architecture
binary's symbol versions are valid but meaningless and would send the reader
down the wrong path.

Release CI builds arm64 on a native runner, so this guards local and future
cross-builds rather than a shipped artifact.

* test(linux): judge per-arch vendored binaries against their own path

The first CI run of the architecture gate failed the x64 package job on
`@parcel/watcher-linux-arm64-glibc/watcher.node`. That binary is arm64 on
purpose: the package ships every architecture and its loader picks the match,
so its presence in an x64 build is correct.

Judge a binary against the architecture its own path names, falling back to
the slice when the path names none. That keeps the case this gate exists for
-- `bin/linux-arm64-*/node-pty.node` holding an x86-64 binary, which is what
shipped to a Raspberry Pi 5 -- while letting multi-arch dependencies through.

Dry-run over the real dependency tree flags nothing for either target arch.

* fix(linux): move deb/rpm update installation outside Orca (#17318)

* fix(linux): complete deb/rpm package metadata

* fix(linux): preserve CLI link during package upgrades

* docs(linux): document local RPM build prerequisites

* fix(linux): move deb/rpm update installation outside Orca

* fix(updater): preserve Linux recovery across stale events

* fix(updater): fence stale downloaded events by active target

* fix(updater): preserve active Linux package recovery

* test(linux): keep workflow order assertion in scope

* test(updater): assert stale recovery stays silent

* fix(updater): preserve Linux package recovery after checks

* refactor(updater): keep Linux marker message with status

* fix(linux): describe the right manual update path for deb/rpm hosts

A remote host installed from .deb or .rpm now reports
manual-service-update-required, and the guidance told the operator to
"update through the service manager that starts this server" -- which is
correct for unsupported-headless-serve but wrong for a package install,
where nothing about the remedy involves the service manager.

Say both, keyed on how the host was installed.

* docs(linux): document orcad update restart safety

* docs(linux): scope restart census omissions

* docs(linux): use absolute service CLI launcher

* fix(serve): validate in-process serve options before startup (#17683)

* fix(linux): stop offering updates a distro-managed install cannot apply (#17918)

Closes #17702.

The resources/package-type marker is authoritative but never checked against
the host, so any repackager that unpacks Orca's .deb -- AUR, Nix, a container
rebuild -- inherits `deb` verbatim. Install feasibility was then computed
after a ~165 MB download, so those users got check -> download -> a card
promising an install command -> a dead end.

Validate the marker against the host: a deb/rpm marker with no matching
package manager in the trusted directories means a package manager owns this
install. This reuses the exact lists and resolver that
buildLinuxPackageInstallCommand already loops over, so a false positive is
impossible by construction -- any host flagged here would have failed with
no-package-manager after the download anyway. The gate only moves that
verdict earlier. Verified across Debian 12, Ubuntu 24.04, Arch, Fedora 40 and
openSUSE Leap: no false positive on a real deb host, correct on every
repackaging host.

The release is still reported, because the user does want to know 1.4.194
exists and to update through their distro; only the download path is closed.
`externallyManaged` is an additive optional field on the existing `available`
status, so older paired clients decode it unchanged. downloadUpdate() refuses
authoritatively, since main owns this verdict rather than the card, and
unwinds any pinned-build state first -- a Linux pinned jump resolves to
'release', and stranding isPinnedBuildActive would silently kill every
background check for the rest of the process.

Note the fix the issue suggests cannot work: electron-updater builds a
PacmanUpdater whose doDownloadUpdate looks for a .pacman asset Orca does not
publish, then dereferences undefined.

* style(cli): restore prettier wrapping on install error copy

* test(linux): re-pin the child-process ratchets and the batch-shim allowlist after the merge
2026-09-02 03:08:01 -07:00
Neil 4bc20cb842 fix(wsl): name an explicit Windows cwd for wsl.exe spawns (#17834)
* fix(wsl): name an explicit Windows cwd for wsl.exe spawns

Removing the worktree Orca was launched from broke every wsl.exe spawn for
the rest of the session. The WSL command builders passed `cwd: undefined`
meaning "the directory is inside the command" -- but CreateProcessW reads
NULL as "inherit the parent's", and the parent's was a \\wsl.localhost path
Linux had just deleted.

Fixes #16463

* fix(wsl): name the spawn directory at the six remaining wsl.exe sites

The first commit fixed the WSL command builders. Six spawn sites were left
inheriting the process cwd, which is the same deletable `\\wsl.localhost`
worktree: `wsl-availability` (both probes), the WSL filesystem watcher, the
agent-hook relay launch, the UNC delete, and the local worktree filesystem.

`wsl-availability` is the one that matters most, and it turns the bug into a
latching false negative. `isRetryableWslProbeFailure` returns false for ENOENT,
so a spawn that failed only because the inherited cwd was gone is cached as
"WSL is not installed" on the 10-minute definitive TTL with exponential
backoff up to 30 minutes. Git keeps working and Orca reports WSL unavailable --
worse than the bug being fixed.

ENOENT stays non-retryable. It is answer-shaped for the reason it is meant to
be -- wsl.exe is not on PATH -- and naming the directory is what removes the
one cause that was not. Making it retryable would instead re-probe every
non-WSL Windows machine on the short window, and would leave the false ENOENT
in place for the other five sites, which have no cache to correct.

Three of these are also on the `runWslProcess` W3 migration allowlist; this is
the interim until they move, and matches what #17837 does inside the runner.
2026-09-02 01:39:48 -07:00
Neil d7123591ce perf(git): pack the loose refs Orca's own fetches leave behind (#17857)
* perf(git): pack the loose refs Orca's own fetches leave behind

Orca strips git's auto-maintenance off every fetch it issues
(GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS) and never compensated, so
nothing in an Orca-driven checkout ever packs refs. One real machine
reached 36,574 loose refs, where `git show-ref -- main` costs 5.2s and
every worktree create pays for it.

Add an idle-time, per-repo `git pack-refs --all --prune`, armed by the
fetches that create the debt. It runs only after ten minutes of quiet on
that repo, only above 1000 loose refs (probed with a walk bounded by that
threshold, not by the backlog), one at a time across the whole app, at
the background admission tier, and never while an agent is working, a
create is prepared or in flight, a worktree removal is deleting refs, the
app is quitting, or the machine is on battery. A user who set
`maintenance.auto=false` or `gc.auto=0` has opted out.

Measured on a 36,001-loose-ref fixture (macOS/APFS, git 2.44):
`show-ref` 5.5-12.2s -> 30-49ms, `for-each-ref` 4.0-10.8s -> 43-48ms.

Also fixes a pre-existing bug the split exposed: `--path-format=absolute`
is ignored before git 2.31, and taking rev-parse's stdout raw collapsed
every repo on such a host onto one fetch-serialization key.

Refs #17828

* perf(git): make idle ref maintenance preemptible and cheaper to probe

The idle veto was one-directional: it stopped a pack from starting during
a create, removal, or agent work, but nothing stopped those from starting
during a pack. A user-clicked Fetch, a branch delete, or a worktree
removal that needed `packed-refs.lock` mid-rewrite could fail with
`unable to create packed-refs.lock` -- a git error with no visible cause.

Make the pack cancellable end to end. An AbortSignal now reaches the
`pack-refs` child and both pre-pack probes, and `pause()` aborts what is
running, waits for it to actually stop, and holds a suspension count so
nothing new starts until the caller releases. Every entry point that
deletes a ref takes that pause: gitFetch, gitPull, gitFastForward,
removeWorktree, forceDeleteLocalBranch, prepareWorktreeCreateCheckout,
addWorktree. Five more triggers close the rest of the window: battery
drop, window focus, quit, the attempt deadline, and any other git command
queueing for an admission slot.

Judge a pack by re-probing the backlog rather than by the child's exit
code. Measured in the field: another Orca session moved a branch
mid-pack, git reported `cannot lock ref`, skipped that ref and packed the
rest -- 36,688 loose refs down to 3. On a machine running several
sessions that is the normal case, and retrying it would be wrong.

Probe with one batched `readdir` per directory instead of streaming
`opendir`, which issues a thread-pool round trip every 32 entries: 177ms
-> 23ms on a real 36,600-ref repository, with half the event-loop lag.
The walk stays strictly sequential so it can never occupy more than one
of libuv's four filesystem threads.

`PackRefsLockOwnership` makes a lock left by SIGKILL attributable, and
only reclaims one when a marker exists, the lock is older than any
pack-refs could run for, and the recorded process is gone.

Refs #17828

* fix(git): wait out the packed-refs lock instead of killing the pack

Measured on Git 2.55/APFS with 37k loose refs: a full `pack-refs --all
--prune` takes 23-32s but holds `packed-refs.lock` for only 0.03-1.37s of
it. The other ~95% is the prune phase, during which a concurrent `fetch
--prune`, `branch -D` or `update-ref` succeeds every time -- per-ref locks
last microseconds and git retries for `core.filesRefLockTimeout`.

So the abort-on-everything design was strictly harmful. SIGTERM into the
prune loop strands an empty `refs/**/*.lock` about one time in five
(9/30, 5/40, 6/30 kills): `tempfile.c` opens the lock O_EXCL before
`activate_tempfile()` links it into the list the signal handler walks,
and a pack does ~36k lock cycles. Afterwards `update-ref -d` on that ref
fails with `cannot lock ref ... File exists`, permanently. On Windows
`taskkill /f` never runs git's handlers at all, so an abort inside the
rewrite strands `packed-refs.lock` every time.

Never signal the child. `packRefs` no longer takes an abort signal; it
polls `packed-refs.lock` and reports the window through a
`PackedRefsLockReporter`. `pause()` resolves when the lock is released --
bounded, and free during the prune -- while the suspension counter still
blocks new attempts. Battery and window-focus become do-not-start rather
than stop-what-is-running, and quit waits for the lock and lets the child
finish orphaned.

For strands that already exist, `PackRefsLockOwnership` now also reclaims
`refs/**/*.lock` under the same three conditions plus a 0-byte check, and
a lock carrying our own not-yet-reclaimable marker records `locked` with
a 30min retry instead of the 6h failure cooldown -- so a Windows strand
self-heals in half an hour rather than six.

Reverts the git admission-scheduler event bus, which existed only to
drive the abort this removes.

Refs #17828

* test(git): make the ref-maintenance waits survive a loaded runner

CI shard 4/8 failed on `restarts every armed countdown when the user does
ref work themselves`, which passes locally. The `until()` helper spun a
fixed 200 event-loop turns and then returned silently, so on a contended
runner the filesystem probe had not finished and the assertion that
followed failed with an unrelated message.

Bound the wait by wall clock instead and throw a named error, which
immediately exposed a second latent bug: the single-flight test's second
wait could never succeed, because the deferred repo's retry is on a faked
`setTimeout` that spinning the real loop never advances. It had been
passing only because the old helper gave up quietly. Add a timer-aware
variant for those, and have the countdown test await a signal the fake
pack resolves rather than polling at all.

Verified stable across five sequential runs and once under load average
32 with six concurrent suites.

Refs #17828
2026-09-01 19:06:44 -07:00
Neil 28214e1ea1 fix(linux): stop re-extracting the AppImage on inode metadata churn
The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime.
ctime moves on any inode metadata write -- `chmod +x`, which every AppImage
user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup
restore -- none of which alter a byte of the payload.

Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime
identical and moves ctime alone, so the key changed and the next launch paid
a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it
already had, then pruned the old generation.

Key on content identity instead. An in-place content change moves mtime and
almost always size; a replacement moves the inode. The existing
replace-in-place test still passes.
2026-09-01 14:06:16 -07:00
Neil a310150e6a fix(linux): bound the CLI registration lock wait
`retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per
attempt an IPC-driven registration could hang ~16 minutes against a wedged
holder with no feedback.

A legitimate holder is bounded by the extraction timeout, so wait that plus
slack and then fail with a message naming the lock file, rather than hanging.
`maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile.
2026-09-01 14:06:16 -07:00
Neil 1767858ea7 fix(linux): reclaim superseded AppImage payloads and packaged symlinks
Pruning removed 3215 of 3216 files from a superseded generation and always
stranded resources/app.asar, leaking ~105 MB per version update. Electron's
asar shim reports a *.asar file as a directory, so the recursive remove tried
to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it.
Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and
519M again once the payload is actually reclaimed.

removeExtractedAppImagePayload holds process.noAsar for the removal, counted
so overlapping removals cannot hand the shim back early, and the prune site
now warns with the path instead of swallowing the rejection. All three
removal sites use it -- staging cleanup and displaced roots leaked the same
way.

Also reclaim symlinks left by a packaged deb/rpm install, which the
extracted-cache-only rule turned into a hard conflict on a deb -> AppImage
migration, and name the remedy in the conflict error.
2026-09-01 14:06:16 -07:00
Neil bbd2047066 refactor(linux): import bundled launcher directly 2026-09-01 14:06:16 -07:00
Neil a2c8859f76 fix(linux): accept extracted AppImage runtimes with APPDIR only 2026-09-01 14:06:16 -07:00
Neil d19a8cdf86 fix(linux): fence AppImage terminal shim mounts 2026-09-01 14:06:16 -07:00
Neil 0079fe2fa8 test(cli): assert registration lock serialization 2026-09-01 14:06:16 -07:00
Neil 4c24a28df0 refactor(linux): trim AppImage CLI registration seams 2026-09-01 14:06:16 -07:00
Neil da4a83bd22 fix(linux): give the CLI one entrypoint by extracting the AppImage once 2026-09-01 14:06:16 -07:00
Neil f116d2ca2a test(ci): retry Windows teardown EPERM and restart evaluate misses (#17780)
Restart-survival polls treated a recycled renderer as a hard failure.
Wrap those evaluates so "Execution context was destroyed" is a pending
miss. Windows package-lane teardowns after a force-kill used rmSync
with force:true only, which does not absorb EPERM; put them on the
shared maxRetries:8 policy.
2026-08-31 18:53:01 -07:00
Chen 07df4bf0be fix(orchestration): recover stripped task deps 2026-08-29 23:07:36 -07:00
Neil 7ee8b5e1a6 Refactor lower max-lines modules (#16760) 2026-08-27 16:10:51 -07:00
Neil 83ffc0df24 refactor Electron facilities modules (#16333)
* refactor oversized Electron facilities

* fix interactive process timeout and shortcut repeat guard

* chore(child-process): drop stale cli-installer allowlist entry

cli-installer.ts now routes privileged spawns through runProcess via
cli-privileged-processes.ts, so the shrink-only ratchet flags it as stale.

* refactor(child-process): extract the bounded output sink

runProcess's timeoutMs opt-out (required to preserve the unbounded osascript
admin prompt) pushed run-process.ts past the 300-line cap. Move createOutputSink
to its own module rather than add a max-lines bypass, which AGENTS.md forbids.
Moved verbatim; no behavior change.
2026-08-25 00:31:31 -07:00
Neil 0bbc6c80e8 refactor(host): route app paths and version through an AppEnvironment port (#16019)
* refactor(host): route app paths and version through an AppEnvironment port

`app.getPath('userData')` is the single largest Electron coupling in the main
process — 37 call sites — and it is one of the things stopping the Orca runtime
from booting on plain Node. Give it the same treatment as SecretStore.

- `src/shared/app-environment.ts` — the port plus a settable registry, covering
  the members the runtime's module graph actually reads: paths, app path,
  version, packaged flag, shutdown hook, exit, and Chromium process metrics.
  `getAppEnvironment()` throws until installed, for the same reason the secret
  store does: a silent default resolves `userData` to the wrong directory and the
  caller writes real state there before anyone notices. No `node:` imports,
  because `src/shared/**` is in the web build graph.
- `src/main/host/electron-app-environment.ts` — the desktop adapter, a
  pass-through to `electron.app`.
- 9 modules migrated: telemetry, opencode/mimo/pi hook services,
  terminal-history-paths, terminal-scrollback-snapshots, cli-installer,
  clipboard-image-temp-file, memory/collector.

Deliberately NOT migrated: `src/main/browser/**`. That cluster is Chromium-
adjacent by nature — cookie jars, download destinations, offscreen pages — and a
Node backend does not ship it at all, so porting it buys nothing and churns
heavily-mocked suites. Also left alone for now: the call sites that additionally
touch `app.asar` path literals or `app.setName`, which need more than a
mechanical swap.

`getAppMetrics` stays on the port rather than being injected because
memory/collector.ts is its only caller and reads it from module scope; a Node
host returns [], having no Chromium processes to measure.

Test wiring: the secret-store setup file becomes `vitest-host-ports-setup.ts` and
installs both ports, exporting `fakeAppEnvironment`/`installFakeAppEnvironment`
so suites needing one specific member state only that instead of restating all
seven — which is boilerplate, and had pushed one suite past the max-lines budget.

Verified: 159 files / 1651 tests pass across every touched area; `tsc` clean on
both the node and web projects; `oxlint` clean.

* fix(typecheck): list the vitest host-ports setup in the node project

Three suites import `installFakeAppEnvironment` from config/scripts, but that
directory is outside tsconfig.node.json's include list, so composite typecheck
failed with TS6307. Listing the one file matches how this config already pins
individual files it needs.

Local `tsc --composite false` does not reproduce this — only `pnpm typecheck`
does, which is what CI runs.

* refactor(host): drop two unused AppEnvironment exports

hasAppEnvironment() and resetAppEnvironmentForTests() had zero callers. The
secret-store equivalents are used, so these were mirror-symmetry rather than
need; add them back when something actually needs them.

* test(terminal-history): install the AppEnvironment fake instead of mocking electron

These three suites mocked `electron.app.getPath` to point at a fixture dir. The
production module now reads the port, so the mock was inert and the global test
default's temp dir won — which broke the WSL path assertions and every deletion
count.

Found by a full-suite run, not by the targeted checks around the migrated modules,
which is the argument for running the whole suite on a refactor this wide.

* test(host-ports): remove the per-environment temp dir on teardown

The setup allocated a mkdtemp directory at module scope, which vitest evaluates
once per test *environment* — one per test file, not one per worker. Nothing
removed them, so a full 6,000-file run left thousands behind.

Proven: with an isolated TMPDIR, a three-file run previously added directories and
now leaves zero.

* fix(app-environment): anchor the installed environment to a realm global

Same reason as the SecretStore: vi.resetModules() rebuilds the module registry,
and an environment installed before the reset read back as uninstalled.
2026-08-22 21:12:23 -07:00
Neil e9e238c883 refactor(wsl): delete the environment-policy layer the reviews kept failing on (#16007)
* refactor(wsl): delete the environment-policy layer the reviews kept failing on

A design council (Opus, Grok, GPT-5.6-Sol) reviewed the merged runner after it
took eleven review rounds to land. All three reached the same conclusion: the
invocation half is sound, the environment/probe half is not, and every round had
been debugging the second one.

The finding that settled it, from Opus: `environmentResolved` had **54
references, all in tests and the runner itself. Not one production reader.** The
safety mechanism the strict default existed for was never wired to anything, so
all 19 degrading sites reported absence with full confidence anyway -- #9725
live at every one, under comments claiming it was handled. Two of those comments
say so out loud; I wrote them.

Root cause, in one line: every knob existed only because a failed probe was
fatal. So it no longer is.

- `allowDegradedEnvironment` and `WslGuestEnvironmentUnavailableError` are gone.
  A missing login PATH is a fact in the result, not an exception. That deletes
  23 opt-outs, six catch-and-remap blocks, the transient/rejected cooldown
  split, `probedWithBudget`, and the 1.5x re-probe heuristic -- none of which
  had a reason to exist once the case stopped throwing.
- `lane` + `allowDegradedEnvironment` collapse into `loginPath: 'none' |
  'preferred'`. 19 of 23 sites passed the opt-out, and two said in comments that
  they did not want the login PATH at all: the flag had become the `'none'` the
  union was missing.
- The `interactive` lane is deleted. It had zero production callers and kept ~30
  lines of fence plumbing alive for tests only.

Net -98 production lines; the runner itself sheds 86 for 38.

Also carries three fixes from the W3 orphan-PR sweep I had not done:
- `WSL_UTF8=1` in the runner. My relay migration deleted the only place setting
  it, so wsl.exe's own error text arrived UTF-16LE and read as NUL-riddled.
  A regression I introduced. Credit: #9010 (Chang-Jin-Lee).
- `GITLAB_HOST` is now named in WSLENV, so a ported self-hosted host actually
  crosses into a distro-routed glab (#12557). Credit: #12558 (makoto-developer).
- The WSL skill-setup command pipes into `sh` instead of `eval "$(...)"`, whose
  nested quoting produced `word unexpected (expecting "in")` (#14292). Credit:
  #14785 (innocarpe).

* fix(wsl): restore the login PATH for the Codex availability lookup

loginPath:'none' on a PATH lookup reports an nvm-installed codex as absent,
which is #9725. A miss without a resolved environment is now 'could not
check', not 'not installed'.

Also hardens the guards that should have caught it:
- bashism ratchet is per-call, not per-file, and fails closed on lexer desync
- blankStringContents handles regex literals (an apostrophe in /'/g desynced
  the lexer, so the scan silently found zero calls)
- windowsHide allowlist 85 -> 80, stale once the lexer parsed those files

Credit: Grok (P0), GPT-Sol (ratchet gaps).

* test(wsl): close the two ratchet gaps that let planted spawns pass

- variable-indirected wsl.exe (`const b = 'wsl.exe'; spawnProcess(b)`) is now
  tracked, so the 5 files recorded only in a comment become real allowlist
  entries. Three actually spawn that way; the other two never spawned wsl.exe
  at all, so the prose record was wrong by three in the hiding direction.
- promisify(renamedAlias) is now resolved, so `const run = promisify(execFile)`
  behind an `execFile as x` import can no longer skip windowsHide.

Each verified by planting the violation, watching it fail, restoring, watching
it pass. Credit: GPT-Sol.

* fix(source-scan): stop the regex-literal reader from eating block comments

At index 0 there is no preceding token, so a file opening with a banner
comment had its `/*` read as a pattern and swallowed to the next slash --
110k characters of preload/index.ts, in the direction that hides offenders.

Measured across the tree, old lexer vs new: worst-case over-blanking drops
from -110564 to -1116 characters, and files that desync drop from 51 to 22.
The remaining extra blanking is regex interiors, which is the intent.

Regression tests for both lexer bugs, each verified to fail with its fix
reverted. The first draft of the comment test did not bind -- it asserted on
text after the swallowed span.

* fix(wsl): restore the unverifiable signal on the two remaining probe sites

Round 2. Three call sites used to throw when the login-PATH probe failed;
the redesign rewired one (Codex) and left two reporting confident absence.

- skill-wsl-provider-detection: the script ends in `|| true`, so a lookup
  without the login PATH exits 0 with empty stdout -- identical to 'nothing
  installed'. Callers skip the ~/.codex and ~/.claude skill roots on an empty
  list, losing an nvm-installed provider's skills.
- wsl-cli-installer: the dead catch is replaced by an explicit check. Its
  `case ":$PATH:"` probe otherwise answers from the distro default PATH and
  Settings states as fact that the CLI is not on PATH. Timeout is checked
  first, since a timed-out run also leaves the environment unresolved.

Also narrows the regex-literal prev-token set. '!', '+', '-', '>' and '}' are
value terminators as often as operators, so postfix `n-- / 2` and JSX
`<A size={14} /> : <B` were read as patterns and their spans blanked -- 13
live JSX spans, and one swallowed execFile call that left no desync behind.
False negatives only risk a desync, and desync fails closed.

Plus: WSL_UTF8 on the probe spawn (#9010 reached the runner, not the probe),
and the allowlist header I shuffled by sorting comments along with entries.

Credit: Grok (both P1s), Opus (lexer false positives).

* docs(wsl): drop the lane comments the redesign made false

The interactive lane is gone, so 'both lanes' and the fenced-stdout note
described code that no longer exists. Also states plainly that
environmentResolved is always true under loginPath:'none' -- the field cannot
rescue a PATH lookup that was mislabelled, which is how #9725 came back.

Credit: Grok.

* fix(wsl): stop piping user scripts into the shell's stdin

The W3 migration moved hooks from `wsl.exe --exec bash -c <script>` to a
script piped into `bash -s`. Anything the script runs that reads stdin then
drains the rest of the script, bash hits EOF and exits 0, and the caller logs
success -- an orca.yaml hook of `ssh -T git@github.com || true` followed by
`pnpm install` silently never installs.

Scripts now travel in argv by default, which is what the pre-migration code
did and what --exec makes safe. `scriptDelivery: 'stdin'` stays for the one
caller that needs it: the hook-relay installer embeds a base64 JS bundle far
past any command-line limit, and reads no stdin.

A runner test already described this exact EOF hazard -- for the login shell,
not for the guest command it was itself creating.

Credit: code review.

* fix(skills): make the unverifiable check unconditional, and stop double-probing

Round 3.

- provider detection threw only on an EMPTY result, so a degraded partial hit
  slipped through: `claude` visible on the default PATH via Windows interop
  plus an nvm-only `codex` returns a plausible ['claude'], and the caller then
  skips the ~/.codex skill roots for a provider that is installed. The
  installer already got this right with an unconditional throw.
- three sites asked for 'preferred' without needing it. The GROK_HOME probe
  runs its own `"$login_shell" -lc`, so the runner's probe was a second login
  shell eating up to half an 8s budget; the two skill scans are
  find/base64/head/printf/stat over $HOME.
- the indirection binder missed `private readonly x = 'wsl.exe'` (the
  modifier was captured as the name), backtick literals, and
  `spawnProcess(this.x)`. Commit 2bbbd99 claimed that gap closed; it now is,
  verified against all three shapes.

Credit: Grok.

* fix(child-process): keep the tail of output whose failure lands last

Two console-flash bugs the ratchet was carrying on its allowlist rather than
catching: daemon-process-inspection execs powershell.exe and the gemini
extractor execs `where gemini`, both console-subsystem, both without
windowsHide (#10488). Allowlist 80 -> 78.

And a migration regression: the hook-relay install used to keep a rolling
tail of stderr (`slice(-MAX)`), while runProcess's maxOutputBytes keeps the
head. A guest install that fails after pages of apt warnings therefore
reported the warnings instead of `mv: Read-only file system`. runProcess
takes retainOutput: 'tail' for output whose meaning is at the end.

Credit: code review.

* test(wsl): close the last two indirection shapes in the binder

`this.binary = 'wsl.exe'` has no declarator keyword, and a helper that just
returns the literal is a spawn one hop away that no regex can follow. The
return case fails closed only when the file also spawns something --
local-windows-terminal-runtime.ts returns the name as terminal metadata and
never spawns, so a blanket rule flagged it wrongly.

Verified against both shapes: planted, failed, restored, passed.

Credit: Opus.

* fix(preflight): stop reporting installed WSL CLIs as absent (#9725)

The last two probe sites that turned an unresolvable login PATH into a
confident negative. The native branch of detectInstalledAgents already
consults install dirs for exactly this reason ('PATH may still be unhydrated
on a cold GUI launch'); the WSL branch had no equivalent, so a cold distro
made an nvm-installed claude/codex read as not installed and told the user to
install a CLI their own terminal runs.

Ports that fallback to the guest: agent detection checks the version-manager
bin dirs for commands the PATH lookup missed, and the preflight command runner
APPENDS them to PATH -- append, never prepend, so a resolved login PATH stays
authoritative and a stale nvm version cannot shadow the real binary.

Tested by executing the generated scripts through /bin/sh against planted
binaries, since the behaviour is shell globbing and [ -x ]. Both the
nvm-discovery and the no-shadowing tests were verified to fail when reverted.

* fix(codex-accounts): hide the console on the legacy active-home migration

execFileSync('wsl.exe') with no windowsHide flashes a conhost and steals
foreground on a GUI-launched Orca (#10488). Sibling WSL spawns got this in
earlier commits; this one only had its quoting rewritten. Allowlist 75 -> 74.

Credit: code review.

* fix(windows): close the shell:true hole that made windowsHide a no-op

I un-allowlisted the gemini extractor after adding `windowsHide: true` to an
`exec()` call. `exec` implies `shell: true`, which this repo's own chokepoint
documents as silently making windowsHide a no-op (#14543) -- so the site still
flashed a conhost while reading as guarded. Now execFile('where.exe', …),
matching the relay sibling that already did it right.

The ratchet could not see that, which is why it passed. It now treats a call
that resolves to exec/execSync, or any `shell: true`, as unguarded regardless
of windowsHide -- including through renamed imports and a renamed promisify.

Also: a script over 8000 chars now falls back to stdin. Windows caps a command
line at 32767 and a user's orca.yaml hook is the one unbounded script Orca
runs (`run-both` concatenates two; a vendored installer is ~15KB), so argv
would fail to spawn outright. Degrading beats failing.

And the binder now sees `let p: string` ... `p = 'wsl.exe'`.

Each verified by planting. Credit: Grok.

* test(wsl): an opaque payload must declare its interpreter

My per-call bashism guard REPLACED the file-wide one, and that was a strict
regression: the real payloads are built in a separate function and passed as a
bare `script,`, so the bashism is never inside the call literal and the
per-call arm cannot fire. Deleting `shell: 'bash'` from skill-discovery-wsl
-- `done < <(find ...)` and `read -r -d ''`, the #14292 signature -- passed on
this branch and failed on main.

Reading through the identifier is guesswork. Requiring the call to name its
shell when the payload is not a literal is not, so seven POSIX call sites now
say `shell: 'sh'` -- no behaviour change, sh was already the default.

Two earlier attempts at this were wrong and are worth recording: a whole-file
BASHISM test blamed codex-accounts/service.ts, which correctly pins bash on its
four inline payloads and correctly leaves printf/mkdir unpinned; and excluding
call text still caught a bash payload belonging to a non-runner execFileSync.

Also: runProcessSync now refuses retainOutput:'tail' instead of silently
keeping the head, and the union docblock no longer describes stdin delivery.

Verified against both of the plants that exposed this. Credit: Opus.

* test(wsl): judge an opaque payload by the file, not by whether shell is set

Round 5. My previous rule -- opaque payload must have `shell:` -- was the
third guard fix in a row that came out weaker than what it replaced:
`shell: 'sh'` on a bash payload satisfied it, which is #14292 with extra
steps. Flipping skill-discovery-wsl's pin from bash to sh shipped green.

Now: strip the text of every call that already names bash, and if a bashism
survives anywhere in the file while a script-carrying call is not bash-pinned,
flag it. Stripping the bash-pinned calls is what keeps codex-accounts clean.

Also closes four ways to hide a call from the collector, each verified by
planting:
- `script: \`${bashism}\`` -- a template literal read as a visible literal
- `runWslProcess({ ...spec })` -- a spread hides script AND shell
- `Object.assign({ a }, { script })` -- the collector took the first `{`, so it
  now takes the whole argument list
- `import { runWslProcess as runWsl }` -- a renamed callee collected nothing,
  and zero calls read as zero violations

Not fixed, recorded instead: a computed `shell:` in the console guard. Matching
any non-false value also flags `shell: spawnConfig.shell`, a pass-through that
is false in every branch, and a false positive there costs an allowlist entry
that disables the guard for a whole correct file.

Credit: Grok.

* fix(preflight): make the guest fallback match the native one it claims to mirror

Three defects in the #9725 fix from earlier today, all found by executing the
generated scripts under real dash rather than reading them.

- $HOME containing a space word-split the unquoted dir list into a relative
  path, so every CLI read as absent -- the exact symptom the fix exists to
  remove. Each entry is quoted now; the nvm entry quotes only its prefix so the
  glob still expands.
- A directory passes `[ -x ]`, so ~/.local/bin/gemini/ was reported as an
  installed CLI that then fails to launch with EISDIR. The PATH half of the
  same script already guarded this, and so does the native twin.
- The header called this the "guest-side twin" of the native fallback while
  omitting four of its directories: volta, asdf, fnm and mise. A WSL user on
  any of those still had #9725 while the same user on native did not -- and
  asdf and mise are named in the motivating comment. The claim is now true.

Credit: Opus.

* test(wsl): mask bash-pinned calls by position, not by String.replace

`rest.replace(text, '')` with a string pattern removes only the FIRST match,
so two identically-written pinned calls left one behind and its bashism then
counted against an unrelated unpinned call in the same file. A body that also
occurred earlier as a substring would blank the wrong region entirely.

The collector now returns ranges and the mask is applied by index. Verified
both directions: two identical pinned bodies plus one unpinned call flags, and
the same file with all three pinned stays clean.

* test(wsl): fail closed on call shapes a regex cannot attribute

Round 6. Rather than widen the pattern again, treat the shapes it cannot
reason about as unreadable.

A regex cannot tell which object a key belongs to, so every round produced
another way to put the pin in one place and the payload in another:
`cond ? {pinned} : {unpinned}`, `{...} as WslSpec`, `Object.assign({a},{b})`.
A call whose SPEC is chosen by a ternary or spread -- one appearing before the
first `{` -- or which carries an `as` assertion is now flagged whenever the
file has a bashism, with no `shell: 'bash'` escape, because the substring test
that would grant the escape is exactly what cannot be trusted on these shapes.

A ternary INSIDE the object is not exotic: claude-accounts/service.ts:977 uses
one to choose a script line in a call that is already pinned, and treating that
as opaque would demand a second pin it already has. Nor is a nested call --
`script: `x ${shellQuote(p)}`` is how every payload here is built, and flagging
it would demand bash on POSIX payloads that must not have it.

Also follows `const run = runWslProcess`, generics and optional chaining, and
counts collected calls against mentions so a shape that slips the pattern reads
as unreadable rather than clean.

I tried the TypeScript parser first, which would remove the class outright.
TypeScript 7 is the native port and exposes no JS compiler API; oxc-parser
works but is transitive, and declaring it surfaced an unmet peer warning.
Recorded here so the next person does not repeat the detour.

Credit: Grok.

* fix(wsl): fish is a PATH lookup, and my lint check could not fail

Two things Opus caught that I had verified wrongly.

`wsl-fish-history-cleanup` passes `program: 'fish'` -- a bare name, so a PATH
lookup by definition, the exact class the earlier rounds hunted. I mapped it to
'none' and then defended that in an audit, because I read
`allowDegradedEnvironment: true` as "does not need the login PATH". It does not
mean that: it means "do not fail when the probe fails". The old call still USED
the login PATH whenever it got one, which is 'preferred'. Under 'none' a fish
from linuxbrew or nix is invisible and the cleanup throws. The truncated
comment left behind when the flag was deleted is finished too.

And `pnpm lint` has been failing on this branch while I reported it clean: I
grepped for `error eslint|error oxlint`, but oxlint prints the rule category
(`error typescript(array-type)`, `error unicorn(prefer-ternary)`). The grep
could not match, so it never failed. Checking the exit code instead surfaced a
third violation hidden behind the first two.

Credit: Opus.

* chore(wsl): clear the round-7 P2s

- Formatting: the branch owned 22 of the tree's 26 oxfmt failures because I
  never ran the formatter. Branch files now own none.
- resolveScriptDelivery was computed twice, in two places that must agree
  about argv shape and stdin payload. Resolved once and threaded through.
- The allowlist header said the list only shrinks while the branch added three
  entries. It grew because the scanner learned to follow a variable-bound
  'wsl.exe'; those three were previously recorded in prose, so the count was
  wrong by three in the direction that hides offenders. The header now says so.
- Two test comments still explained behaviour via the deleted
  allowDegradedEnvironment flag; a stray triple blank line; two adjacent JSDoc
  blocks where only the second attached.

Not taken: platform-guarding addWslEnvKeys. WSLENV is inert off Windows, and
the guard broke a test that asserts the key directly -- more surface than the
tidy is worth, so the reason is recorded at the call site instead.

Credit: Opus.

* test(preflight): plant a fabricated CLI name, not a real one

CI caught what my local run could not: the runner has a real /usr/bin/gh, so
`command -v gh` resolved to it and the planted nvm stub was never reached. The
fallback APPENDS, so that is the code behaving correctly -- the test was
asserting a property of my machine.

Both real-shell suites now plant `orca-fake-cli`, which exists nowhere.
Re-verified the same way as before: with the PATH fallback disabled the test
fails, with it restored it passes.

I declared this branch merge-ready without looking at CI. Local green is not
the gate.

* refactor(wsl): delete two knobs and a duplicated fallback

Elegance pass. The branch had grown from a deletion into a net addition, and
most of the growth was optional axes with one caller each.

- `retainOutput` is gone. One production caller wanted the tail of a 64KiB
  buffer; head-truncation only hurt because of that cap. The caller drops the
  cap, keeps the default, and slices the tail itself -- which is what the live
  relay next door already does. Two mechanisms for one job became one.
- `scriptDelivery` is gone. The size rule was already the whole design:
  argv unless the script is too long for a Windows command line. The option
  existed so a small Orca script could opt into stdin, and no such caller ever
  appeared. Both behaviours stay pinned: a huge script still goes to stdin, an
  ordinary one still leaves the hook's stdin free.
- Agent detection no longer walks the fallback dirs itself. It prepends the
  same PATH prelude the preflight command runner uses and lets the ordinary
  lookup do the work. Its bespoke walk had duplicated the lookup script's
  `! -d` guard -- and had missed it once, which is how a directory read as an
  installed CLI.

All 17 detection tests still pass unchanged, including the $HOME-with-a-space,
directory-is-not-a-CLI, and volta/asdf/fnm/mise cases, so the collapse is
behaviour-preserving rather than assumed to be.

Credit: Grok.

* fix(wsl): never name a path-shaped variable in WSLENV

`buildHostEnv` forwarded every caller-supplied key into WSLENV. wsl.exe
translates path-shaped variables between Windows and Linux form, so a caller
passing PATH would have replaced the guest's own PATH with a translated
Windows one -- silently, and fatally for every lookup after it.

No caller passes PATH today. The point of a chokepoint is that it does not
depend on that staying true.
2026-08-22 20:35:59 -07:00
Neil 5651662494 fix(wsl): migrate 21 call sites onto the WSL runner (#15923)
* fix(wsl): migrate 21 call sites onto the runner, after five review rounds

Rebased onto main now that the runner (#15903) has landed.

21 sites across 15 files move off ad-hoc `execFile('wsl.exe', ...)`. Allowlist
23 -> 16 on the WSL guard; 163 -> 152 on the W1 child_process guard, which moved
as a consequence.

Five review rounds, each finding real defects -- several introduced by the
previous round's fixes:

1. Hooks ran user orca.yaml scripts under dash; probe failure fell back to the
   login shell, reintroducing the ~/.profile stall the runner exists to remove.
2. An unparseable probe was cached permanently, disabling every WSL feature on
   the distro; hooks regressed from "runs degraded" to "fails".
3. Exit 127 had no expiry; a starved 5s probe hard-failed the 10s scan behind
   it; a joiner burned its budget on someone else's probe.
4. The comment stripper blanked live code, so the windowsHide guard walked past
   a real unguarded spawn and reported the file clean; an ownership-probe
   timeout silently deselected the user's Claude account.
5. Verification of the guards themselves.

The recurring finding -- a call answering "is this installed?" on a degraded
PATH -- was eventually fixed structurally rather than per-caller: the runner
refuses an unresolved guest PATH unless the caller opts in. Per-site vigilance
was demonstrably not holding; 3 of 8 sites had already forgotten the analogous
exit-code check.

Remaining 16 files need a runner mode that does not exist: a long-lived
streaming child (OAuth logins, hook relay), a synchronous caller, or a
host-level flag like --status that the guest-command API cannot express.

* fix(wsl): close round 5's P1s -- degrade where PATH was never needed

Round 5 measured the guards by re-executing their algorithms standalone rather
than reading them, and found four things.

P1 -- four skill/plugin paths gained a hard dependency on the login-shell probe
that they never had. They ran under a plain non-login `sh -c` on main, so a
probe failure now breaks WSL skill discovery and install on exactly the distro
the runner was built for: one with a slow `~/.profile`. Worse, the throw escapes
before each site's own error mapping, so the UI gets a raw internal string. They
degrade now, per the rule this branch already wrote down in
`wsl-fish-history-cleanup.ts`.

P1 -- Codex and Claude were asymmetric. Claude's five credential sites degrade;
Codex's were strict, so adding a WSL Codex account failed where adding a Claude
one succeeded. Three of the four are byte-equivalent to Claude sites, and their
scripts read `$HOME`/`$WSL_DISTRO_NAME`, which wsl.exe supplies without a login
shell. `assertWslCodexCliAvailable` stays strict on purpose -- that one really
does answer "is this installed?" (#9725).

P1 -- the ownership-probe timeout fix did not survive the rebase onto main. A
timeout still returned "not owned", which the caller *persists*, clearing the
user's account selection.

P1 -- `blankStringContents` desynced on a nested template literal
(`` `${`x`}` ``), leaving 116 lines of a child_process importer outside the
ratchet, with 27 importers structurally at risk. Now tracks template depth.
Regenerating against the fixed blanker: 70 -> 68 offenders.

Also: the windowsHide vacuity check could not fail while the allowlist alone
exceeded its bound -- the exact defect the sibling guard documents avoiding. It
now names a file that definitely offends.

* fix(wsl): close round 6 -- my blanker fix had traded a false positive for a miss

Round 6 re-derived the guard's answer from a TypeScript AST instead of trusting
the regex, and caught two things.

P1 -- the nested-template fix I shipped in round 5 introduced a worse bug than
the one it closed. Switching to "code mode" inside `${...}` without also
resetting the quote at a newline meant an apostrophe in a regex literal --
`` `'${value.replace(/'/g, "'\\''")}'` `` , which is exactly the shellQuote
shape all over this codebase -- inverted the lexer for the rest of the file.
`claude-accounts/service.ts` went blind from line 96, hiding a REAL unguarded
`spawn` at :1097: the WSL Claude managed-login path, which opens a console and
steals foreground on Windows. Round 5 traded one false positive for one false
negative and I did not notice, because the offender count went down.

The blanker now resets non-backtick quotes at a newline (the rule stripComments
already had) and tracks brace depth per interpolation. The spawn is fixed rather
than allowlisted, and the count is 69 -- the number the AST predicted.

P1 -- the ownership-timeout guard was dead code: it threw into its own `catch`
three lines below, which returned null, which the caller persists as "not owned"
and clears the user's account selection. Now a typed sentinel the catch rethrows.

P2 -- `WslGuestEnvironmentUnavailableError` reached the UI verbatim from the CLI
installer and the Codex availability check. Both mapped.

Method note: I had been regenerating the allowlist with a Python transcription
of the scanner, and the two drifted -- the same two-implementations problem this
workstream keeps finding. The allowlist is now generated by running the shipped
test with an empty list and taking what it reports.

* fix(guards): stop patching the lexer -- make the scanner fail closed instead

Round 7 proved my round-6 fix also did not work, by planting a plainly-named
unguarded `spawn` in `claude-accounts/service.ts` and watching the guard pass
3/3. That is three consecutive attempts at an exact lexer, each shipping a
desync that hid real calls, and each time the offender count went DOWN, which I
read as progress. Round 6's diagnosis was wrong too: the culprit is the
`templates` brace-depth stack, which nothing resets, not quote state.

So stop trying to be exact. `blankStringContentsDesynced` reports when the lexer
lost its bearings, and the guard treats that as an offender. Over-reporting is a
nuisance; under-reporting is a false clean, and a false clean is what let a real
console-flash spawn out of the ratchet twice. The allowlist goes 69 -> 82: the
13 extra are files whose scan cannot be trusted, now named rather than assumed
fine.

The planted violation is now caught.

Also from round 7:
- `SPAWN_CALL` missed promisified and renamed bindings, so `exec('where gemini')`
  (a real Windows cmd.exe spawn) and a detached `shell: true` in
  `cli/runtime/launch.ts` were invisible. Added execAsync/execFileAsync/
  execFileCb/spawnDetached.
- `BASHISM` matched `set -o pipefail` but not `set -euo pipefail`, which is the
  only spelling this tree uses -- so the check could not have caught the #14292
  signature it exists for. Fixed, and it immediately flagged a file; that one
  turned out to be a comment, so the bashism scan now strips comments too.
- The CLI installer error mapping my round-6 commit claimed was "both mapped"
  was never applied -- only the Codex side had been. Now actually mapped.

* fix(guards): close the four holes round 8 found by planting violations

Round 8 stopped reasoning about the guard and planted spawns into it. Four
holes, none of which reading had found:

- `windowsHide: false` **passed**. The check was `args.includes('windowsHide')`,
  a substring test. Now matches `windowsHide: true`.
- A ternary first argument was silently skipped: the method-declaration filter
  `/^\(\s*\w+\s*[:?]/` also matches `exec(useAlt ? 'a' : 'b', …)`. Now requires
  a type after the colon.
- Renamed bindings were not covered, despite the comment I wrote saying they
  were -- I had hardcoded three names. Aliases are now resolved from the import.

Each is verified closed by planting it and watching the guard fail.

`fork` is deliberately still unscanned. Round 8 is right that Node forwards the
option, but `ForkOptions` does not declare it, so the two live sites cannot be
fixed without a cast. Recorded in the verification doc rather than left as a
silent gap, along with two others worth knowing: the allowlist is file-granular,
so its ~18 false-positive entries carry a standing pre-approval for real
regressions in those files and cannot be retired by fixing code; and
`stripComments` has no desync report, so the fail-closed check is only half
applied.

The doc now also says how to verify a guard change: plant a violation. Every
guard fix here that was verified by reading was wrong.

* fix(wsl): stop preflight reporting installed CLIs as absent on a slow distro

Round 9's merge blocker, and the sharpest finding of the whole workstream: the
branch built to close #9725 had reopened it from the other side.

`preflight-wsl-command.ts` was one of five sites without
`allowDegradedEnvironment`, so a guest-PATH probe failure threw. Every consumer
collapses a throw into a verdict: `isCommandAvailable` and `isCommandOnPath`
catch to `false` ("not installed"), `isGhAuthenticated` and `isGlabAuthenticated`
read an empty payload as "not authenticated". So a slow distro made WSL git, gh
and glab read as missing.

Two things made it likely rather than theoretical. The probe took two thirds of
a 5s budget, leaving the command ~1667ms where main gave it the full 5s inside
its own login shell -- a cold WSL VM start routinely lands in that band. And a
probe timeout is cached for 30s with a re-probe threshold of 1.5x the failed
budget, which a 5s caller can never clear, so every preflight command
short-circuited without spawning wsl.exe at all -- and Re-check does not
invalidate the cache.

Fixes: preflight degrades instead of refusing, and the probe is capped at half
the caller's budget and at 4s, so no caller ends up with less time than it had
before the runner existed.

Also fixes a real console flash found on the way: `preflight-command-exec.ts`
spawns git/gh/node through `promisify(execFile)` with no `windowsHide`.

Round 9 also confirmed the credential paths are now *safer* than main: all 11
account sites degrade, every destructive guest operation is still marker-gated,
and main's `getOwnedManagedAuthPath` could disown an account on a 5s timeout --
which this branch turns into a failed launch instead of a destroyed selection.

* fix(wsl): make "Try again" able to succeed, and test the round-9 fix

Round 10 returned MERGE with one residual worth closing first.

A transient probe failure left the null-resolving promise in `inFlight`, so the
only way back was `retryAfter` -- and the 4s probe cap made the 1.5x budget
escape unreachable, because no caller can pass more than 4s. For the full 30s
window the four non-degrading sites returned their error *without spawning
wsl.exe at all*, and each of those errors says "Try again". The advice was
guaranteed to fail.

The entry is now dropped on a transient outcome and an explicit cooldown gate
replaces it, so the window alone decides. The window drops 30s -> 5s: long
enough to stop a stampede, short enough that the user's next click reaches a
distro that has since warmed up.

Round 10 also noted the round-9 fix shipped untested, which was fair. Added: the
probe-budget floor for 5s/8s/10s callers, and preflight's degrade opt-in plus
its stdout/stderr-carrying rejection, which isGhAuthenticated reads off the
caught error as an auth-success fallback.

* test(wsl): make the probe-budget guard actually guard

Round 11 caught that the regression test I added for the probe cap did not
bind: it seeded the guest environment, so the probe resolved in ~0ms and the
assertion read the command leg's timeout instead. Reverting the cap to the old
2/3 split left all three cases green.

Dropping the seed and asserting on the probe leg fixes it -- verified by
reverting the cap and watching all three fail.

A regression guard that cannot fail is the shape that has cost the most in this
workstream: the windowsHide guard silently passed a real unguarded spawn twice
for the same reason.
2026-08-22 05:45:21 -07:00
OrcaWinandOrcaWin 6e8da1df8d fix(wsl): pass guest argv verbatim through --exec (#15039)
* fix(wsl): pass guest argv verbatim through --exec

`wsl.exe <...> -- <argv>` expands `$name` in every argument against the
guest environment before the guest ever runs. It does this even when no
shell is involved, so `-- /usr/bin/printf %s '$HOME'` prints /home/you.

Every WSL invocation went through that preprocessor, so scripts arrived
already rewritten: `awk '{print $2}'` lost its field reference, and a
POSIX script asking for the literal `$HOME` got the expanded path.

`escapeWslShCommandForWindows` tried to compensate by escaping `$`, but
it skipped any `$` preceded by a backslash, so a script containing `\$`
was still corrupted -- and half the call sites never applied it at all.

Route every invocation through `--exec`, which passes argv through
untouched, and delete the escaper. The direct-git path already used
`--exec`, so this is not a new compatibility dependency.

A guard test fails if the `--` form reappears anywhere in the tree.

Net -50 lines of production code.

* test(wsl): drop remaining escaped-dollar assertions

* fix(wsl): cover the --exec migration's blind spots

An audit of every wsl.exe invocation found sites the first pass missed,
including two it actively broke:

- config/scripts/wsl-git-shell-benchmark.mjs imported
  escapeWslShCommandForWindows, which no longer exists, so the script
  threw on startup. Its wslShellArgs helper also still used `--`; the
  file already had an --exec helper, so route both call sites there.
- classifySubprocessCommand unwrapped `wsl.exe <...> -- <binary>` by
  breaking on `--` alone. With every Orca spawn now on --exec it never
  found the guest binary and bucketed all WSL subprocesses as plain
  "wsl", losing the git/gh/glab breakdown. Break on either separator,
  since foreign wsl.exe processes still use `--`.

CliSkillRuntimeSetup builds its setup command as a template literal
rather than an argv array, so no array-shaped search could see it. Its
decoder accepts both separators so commands persisted before this
change still decode.

The guard now scans config/ and tests/ as well as src/, and checks the
command-string spelling alongside the argv one — the two shapes that
have each shipped a regression. It skips comment lines so prose about
the old form stays allowed, and asserts it scanned a plausible file
count so a bad root cannot make it vacuous.

* fix(wsl): restore the guard's multi-line sensitivity

The guard matched line by line, so `'--',\s*'bash'` could not span a
newline -- and every argv array in this repo is formatted one element
per line, which is exactly the shape it exists to catch. Measured
against the pre-migration tree it caught 17 files before and 9 fewer
after. It now strips comment lines and matches the rejoined text, with
a case that pins the multi-line shape so this cannot silently return.

The program list is wider than shells now, which surfaced a false
positive: tmux takes a `--` separator followed by a program too
(`split-window ... -- cat`). Matching is scoped to files that mention
WSL rather than narrowing the list back.

Also:
- Replaced the `sed` regression case, which was vacuous. A backreference
  contains no `$`, so it returned `bac` under both separators and would
  have passed without the fix. The block claimed every case proved the
  bug. Swapped in a positional argument and a shell local, both measured
  to differ -- the positional is the shape `wslUncDirectoryExists` uses,
  where `--` blanked `$1` so every existing directory probed as missing.
- windows-shell-args.test.ts derived its expected argv from
  buildWslExecArgs, the helper under test, so six assertions would still
  pass if it regressed to `--`. Spelled the expectation out.
- Dropped two comments citing the removed `--` behavior as rationale.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-18 02:12:21 -07:00
Neil 9367169888 refactor(tests): split every oversized test file off the max-lines suppression list (#14728)
* refactor(tests): split oversized test files off the max-lines suppression list

Every `*.test.ts`/`*.spec.ts` that carried an `eslint/oxlint-disable max-lines`
directive is now split into focused, behavior-scoped suites that fit the 800-line
test budget, with shared setup extracted into co-located `*-test-harness.ts` /
`*-test-fixtures.ts` modules (300-line budget). 83 files became ~930; the largest
output is 797 effective lines. `orca-runtime.test.ts` is intentionally untouched.

Test bodies were moved by scripted line-range slicing rather than retyped, so
assertions are byte-identical. The only permitted body edits were mechanical
rebinding where a shared value moved into a harness (e.g. `tmpHome` ->
`homes.tmpHome`).

Registries that enumerate test files were updated in lockstep:
- config/max-lines-baseline.txt: pruned 341 -> 258 entries (all 83 removed).
- config/reliability-gates.jsonc: 33 gates repointed at the split files, with
  assertionRefs split per file where a gate's coverage now spans several.
- .github/workflows/pr.yml: the real-zsh lane now lists the 4 split files that
  actually exercise zsh, so they keep running in the dedicated shell lane.

Also renamed agent-hooks `server-test-fixtures.ts` to `server.test-fixtures.ts`
so the global-fetch call-site audit keeps skipping it, and added `.js` extensions
to the CLI suites' dynamic harness imports (node16 resolution) to unbreak
`build:cli`.

Verification: full suite 52,449 passing vs 52,448 at baseline with zero
assertions lost; `pnpm lint`, `pnpm typecheck`, and `pnpm build:cli` all exit 0;
the terminal-pane e2e spec runs 31/31 headless.

* refactor(tests): split hook-idle arbitration suite that oxfmt pushed over budget

The pre-commit oxfmt pass reflowed pty-connection-hook-idle-arbitration.test.ts
to 811 effective lines, 11 over the test budget. Split the hook-completion side
effect and replacement-agent veto cases into their own suite; both files now sit
well under the cap and the 15 tests are unchanged.

* test: port upstream test changes into the split files after rebase

Rebasing onto main surfaced 27 tests that main had added to files this branch
deleted, plus edits to tests that had already moved. Taking the deletion side of
those modify/delete conflicts would have dropped that coverage silently, so each
upstream change is ported into the split file that now owns the behavior — for
example main's six orchestration mailbox tests land across orchestration-runs,
-send, and -check.

Also repoints `orchestration.notification-mailbox-consistency`, a gate main added
after this branch's gate remap, at those same three split files, and re-prunes
the max-lines baseline against main's (257 entries).

Verified: all 27 upstream test titles present; full suite 52,761 passing with the
only diff vs baseline being 12 tests main itself removed and 3 that moved from
skipped to passing; lint and typecheck exit 0.

* fix(test): flush pending continuations before tearing down terminal test globals

CI shard 5/16 failed on both Node 24 and 26 with `ReferenceError: window is not
defined` from pty-connection.ts, surfacing through
pty-connection-daemon-snapshot-replay.test.ts.

The reattach/settle chains `await` a real promise and then touch `window.api`.
Under fake timers those continuations cannot run, so they only become schedulable
once restoreTerminalTestGlobals() switches back to real timers — which previously
happened immediately before `delete globalThis.window`, so a late continuation
threw and failed the whole file. Flush async ticks in that window instead.

This is latent in the source rather than new: the pre-split 25k-line file kept
running other tests after these, which gave the chains time to settle before
teardown. Splitting the file moved teardown directly behind them.

* fix(test): keep an inert window after terminal test teardown instead of deleting it

The async-tick flush was not enough: the reattach/settle chain can resolve after
teardown regardless of how long we drain, so CI shard 5/16 still failed with
`ReferenceError: window is not defined` from pty-connection.ts.

A real renderer never loses `window`, so deleting it was the artificial part.
Swap in an inert proxy whose properties resolve to callables and whose calls
resolve to undefined, making a late `window.api.pty.*` call a harmless no-op.
The next test replaces it wholesale via installTerminalTestGlobals(), and no test
asserts that `window` is absent.
2026-08-15 00:54:20 -07:00
Brennan Benson b614908ccc fix(codex): resolve the launch preflight to a verified absolute Orca CLI path (STA-4270) (#14458)
* fix(codex): resolve the launch preflight to a verified absolute Orca CLI path (STA-4270)

The Codex launch preflight carried a bare command name ('orca' / 'orca-dev') in
ORCA_CODEX_LAUNCH_PREFLIGHT. The codex() wrapper that invokes it is emitted after
the user's profile scripts are sourced, and those routinely rewrite PATH, so the
name was resolved against a PATH Orca neither controls nor can predict.

Resolve and verify the shipped CLI's absolute path instead, and return null when
no path verifies so the preflight is skipped rather than run against an
unidentified program.

* test(codex): align bundled launcher fixture across CI hosts
2026-08-14 13:09:50 -07:00
Langning ZhangandJinwoo-H 3246b73add Forward Unix launcher signals to orca serve supervisor (#14071)
* fix(cli): forward Unix launcher signals to serve

* test(cli): retry incomplete listener state writes

* test(cli): cover both Unix launcher termination signals

* test(cli): cover macOS launcher signal oracle

* refactor(cli): keep Unix launcher exec atomic

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-08-13 20:05:49 -07:00
Neil 991a3fe963 chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901)
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.

typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.

oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.

Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom

electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
2026-08-11 18:19:43 -07:00
BingZandBrennan Benson d6e1d84235 fix(wsl): forward native CLI arguments losslessly (#12582)
* fix(cli): preserve WSL --deps quotes and parse task ids strictly

PowerShell 5.1 native splat was stripping ASCII double quotes on the WSL
bridge, so non-empty JSON --deps arrays failed while [] still worked.
Pre-escape quotes before launching orca.exe, and recover quote-stripped
task-id arrays while rejecting non-task-id and malformed CSV input (#12188).

* fix(orchestration): narrow WSL deps recovery

* fix(wsl): forward native CLI arguments losslessly

* ci(windows): exercise WSL PowerShell argv boundary

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-11 13:49:12 -07:00
OrcaWinandBrennan Benson e05c7f1625 fix(wsl): preserve forwarded flags in PowerShell 5.1 (#13426)
* fix(wsl): prevent PowerShell flag prefix binding

* chore(wsl): remove root-level reproduction report

* fix(wsl): preserve bridge-first launcher upgrades

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-11 13:20:16 -07:00
OrcaWin 8c975b9d15 fix(terminal): avoid Windows PATH registry subprocesses (#13485) 2026-08-10 19:55:29 -07:00
50594c55a9 Stop the Windows Orca CLI from crashing when the environment carries both PATH and Path (#12218)
* fix(windows): stop the Orca CLI dying on a duplicated PATH/Path environment

The packaged Windows `orca.exe` launcher read
`ProcessStartInfo.EnvironmentVariables`, whose lazy getter copies the
case-sensitive process block into a case-insensitive dictionary via `.Add`.
An inherited block carrying both `PATH` and `Path` threw
`ArgumentException: Item has already been added. Key in dictionary: 'PATH'`,
so every `orca` invocation exited 1 before Electron started
(native/windows-cli-launcher/OrcaCliLauncher.cs:46, printed at :67).

The launcher now mutates its own environment with
`Environment.SetEnvironmentVariable` and never touches either
`ProcessStartInfo` env property, so `CreateProcess` passes a NULL environment
block and the child inherits the live one verbatim.

Orca was also minting the duplicate itself. `applyTerminalAttributionEnv`
read `baseEnv.PATH` and unconditionally wrote `baseEnv.PATH`, so a Windows
PTY that inherited `Path` got a second spelling; which one the child resolved
was non-deterministic. `createLaunchEnv` did the same and, because its read
always missed on Windows, shipped Agent Teams terminals a `PATH` containing
only the tmux shim dir.

`resolvePathEnvKey` (extracted from the existing precedent in
windows-environment-path.ts) now drives every PATH read and write in the PTY
env pipeline, and attribution collapses Windows onto the single OS-resolved
spelling. Off Windows the resolver always returns `PATH`, so POSIX behavior
is unchanged and a case-sensitive POSIX `Path` variable is never touched.

Closes #12046

* test(windows): track the launcher's own-environment marker

The #12046 fix moved ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER and ORCA_CLI_COMMAND
off ProcessStartInfo.EnvironmentVariables, but this asset test still pinned the
old dictionary writes and failed.

Co-authored-by: Orca <help@stably.ai>

* fix(windows): follow the host block's PATH spelling on sparse daemon env patches

Resolving a path-less Windows env to `Path` handed the daemon's own
`{...process.env, ...opts.env}` merge both spellings when the host block spelt
`PATH`. Fall back to the host block's own key, and collapse again inside the
daemon since that merge happens after attribution.

Co-authored-by: Orca <help@stably.ai>

* fix(windows): resolve the live PATH spelling by block order, not casing

Win32 resolves a duplicated variable by taking the first case-insensitive
match in the block, so `resolvePathEnvKey`'s hardcoded `Path`-first
preference targeted the shadowed spelling on the reporter's own
`["PATH","Path"]` block. Drop the attribution-side collapse with it: it
deleted the other spelling's value, and deleting the live key promotes
the shadowed one, so an env that stripped down to empty lost both.

* chore: drop unrelated merge formatting

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 20:45:23 -07:00
Siddiqui QamarandOrcaWin f3c824bc28 fix(terminal): expand environment variables in Windows PATH (#11987)
* fix(terminal): expand variables in Windows PATH

* fix(terminal): preserve expanded Windows PATH at spawn

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 01:00:58 -07:00
OrcaWinandOrcaWin dde72f85de fix(windows): separate updater from orchestration migration (#11405)
* fix(windows): separate updater from orchestration migration

* fix(terminal): attest adopted reveal identity

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-29 15:23:06 -07:00
OrcaWinandOrcaWin 363e478909 fix(orchestration): preserve active workers across updates (#11271)
* fix(orchestration): preserve active workers across updates

* test(ssh): model absent legacy adoption

* test(orchestration): align compatibility contracts

* fix(windows): escape updater PowerShell booleans

* fix(windows): restore stock uninstall process check

* fix(orchestration): keep recovery off renderer startup barrier

* fix(orchestration): harden legacy recovery migration

* fix(orchestration): close recovery review gaps

* fix(orchestration): complete legacy worker cutover recovery

* fix(orchestration): preserve legacy workers across updates

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-29 11:31:35 -07:00
Neil badf91101b fix(quality): enforce performance-safe lint baseline (#11074)
* fix(quality): clear safe existing lint findings

* fix(quality): keep lint cleanup allocation-free

* fix(quality): enforce performance-safe baseline

* test(terminal): drain deferred confirmation cleanup
2026-07-27 20:54:02 -07:00
NeilandOrca aab112933e Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai>
2026-07-23 18:35:31 -07:00
Neil 8f40ddf328 fix(memory): bound OOM-prone accumulators (#10179) 2026-07-23 06:22:56 -07:00
NeilandOrca 190de8223e refactor(comments): slim verbose comments in main integrations (git/providers/…) (#9543)
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.

Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.

Area: main — git, source-control, providers & integrations. 40 files changed, 1432 insertions(+), 4473 deletions(-).

Co-authored-by: Orca <help@stably.ai>
2026-07-20 03:18:28 -07:00
Jinwoo Hong 808299cd1f fix(cli): avoid Windows PATH status timeout (#9483) 2026-07-19 21:59:22 -04:00
Brennan Benson 31f643ca42 Add version-matched skill guides to the CLI (#8624)
* Add version-matched bundled skill guides

* Clarify skill freshness rollout PRs

* Add canonical skills show alias

* fix(skills): address guide review feedback

* fix(skills): make guide commands cross-platform

* fix(skills): apply the ORCA convention to the emulator guides

Review follow-up: the emulator guides still instructed literal
`orca emulator ...` in sh fences with no Linux disambiguation, so on
unmanaged Linux they could launch the GNOME screen reader — the exact
failure the executable-selection preamble prevents. Both emulator
guides now carry the preamble and ORCA placeholder across fences,
tables, and prose, and the cross-platform safety test covers all four
converted guides. Also replaces computer-use's "unless a block names a
shell" carve-out, which contradicted its own POSIX example, with the
unconditional placeholder rule.
2026-07-14 02:17:55 -07:00
Jinjing 302b97029a P2 windows cli hardening (#8638)
* fix(cli): harden Windows launcher transports

* Fix csc.exe compile failures on space-bearing Windows install paths

- Legacy csc.exe mangles absolute paths containing spaces, so the
  compile step now cd's into the bin directory and passes bare
  file names for /out and the source file instead of full paths
2026-07-13 18:58:21 -07:00
f90cd6ebc9 fix(cli): preserve WSL cwd through the Windows bridge (#6965) (#7640)
* fix(cli): preserve WSL cwd through the Windows bridge (#6965)

# Conflicts:
#	src/cli/index.test.ts
#	src/cli/index.ts

* fix(cli): preserve bridge exit codes (#6965)

* fix(cli): harden WSL cwd bridge compatibility

* chore(cli): align cwd tests with main

* fix(cli): repair deleted WSL cwd before path conversion

* chore: preserve main formatting after merge

---------

Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-13 15:44:32 -07:00
Jinjing 34a9df7795 Fix wsl cli rc4 migration (#8471)
* fix(cli): migrate managed WSL launchers to native exe

* refactor(cli): reconcile managed WSL registrations

* fix(cli): harden WSL registration reconciliation

* fix(cli): serialize and harden WSL CLI registration lifecycle

- Cap per-distro concurrent repairs and add a filesystem lock so two
  Orca installs can't race the same distro's launcher/bridge files
- Skip re-adopting a legacy managed launcher when its bridge is user-
  owned, avoiding a doomed install retried on every startup
- Track per-distro reconciliation against launcher target + app
  version so unchanged registrations aren't re-probed each boot
- Replace command install with a single atomic rename instead of a
  move-based backup, closing a window where no orca-ide exists
- Remove the registry invalidate-on-persist-failure path now that
  writes are atomic and repair is disk-authoritative
- Add brief negative caching to WSL distro discovery to bound
  wsl.exe respawns on persistently broken hosts
- Extract keyed-promise-queue as a shared serialization primitive
2026-07-12 23:19:15 -07:00
Neil ee82d66a35 fix(cli): preserve multiline arguments on Windows (#8374)
* fix(cli): preserve multiline Windows arguments

* test(cli): run Windows launcher regression in CI

* fix(cli): support Windows Framework C# compiler
2026-07-12 02:13:41 -07:00
NeilandOrca 42cf64cdc9 fix(linux): stop Orca terminals from launching the GNOME Orca screen reader via bare orca (#8347)
On Linux the CLI installs as orca-ide so it never shadows /usr/bin/orca
(GNOME's screen reader), but agent-facing surfaces (orca-cli skill,
dispatch preambles, CLI hints) all invoke bare `orca` — so on stock
Ubuntu an agent inside an Orca terminal launched the screen reader,
which started speaking (#7904).

Fix: prepend a userData-scoped shim dir (bare `orca` -> bundled
orca-ide launcher, or the stable AppImage) to the PATH of every
packaged-Linux managed PTY, mirroring the existing dev-mode cli/bin
prepend. The user's own shells — and their real screen reader command —
stay untouched. Also flip the orca-cli skill probe to prefer orca-ide
so agents outside Orca terminals never execute the screen reader.

Fixes #7904

Co-authored-by: Orca <help@stably.ai>
2026-07-12 00:38:35 -07:00
NeilandOrca 11e537616c Surface a friendly error when Windows blocks the user PATH write (#6412) (#6588)
* Surface a friendly error when Windows blocks the user PATH write

When HKCU\Environment is locked (Group Policy / restricted ACL / EDR-managed
environment), the PowerShell SetEnvironmentVariable write rejects with a raw
'Command failed: powershell ...' error (often mojibake-localized) that the CLI
Settings toast displayed verbatim. Detect the permission failure via the Latin
PowerShell error-envelope tokens and rethrow an actionable message naming the
exact folder to add to PATH manually, chaining the original error on cause.

Fixes #6412

Co-authored-by: Orca <help@stably.ai>

* Tighten Windows PATH permission detection

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-11 13:43:31 -07:00
NeilandOrca e33b2006f4 Remove stale max-lines lint disables from files under the limit (#7548)
110 files carried an eslint/oxlint-disable max-lines directive but are
already under the default max-lines budget (300 .ts / 400 .tsx / 600 .mjs
/ 800 test), so the suppression is dead. Removing it restores real
max-lines coverage on these files with zero behavior change.

Each removed directive had max-lines as its only rule; verified via a
full oxlint run (0 max-lines violations, 0 new errors). Diff is pure
deletions (200 lines, 0 additions) — no code touched.

Co-authored-by: Orca <help@stably.ai>
2026-07-06 02:12:32 -07:00
5124b0d0ad fix(serve): install orca CLI in headless serve so orca claude-teams resolves (takeover of #5766) (#7267)
* fix(serve): install the orca CLI in headless serve mode

In headless serve mode the orca CLI command (~/.local/bin/orca on Linux,
/usr/local/bin/orca on macOS) is never created, so the in-app Claude Team
launcher (orca claude-teams) opened by 'Start new agent' fails with
command-not-found. CliInstaller.install() is only invoked from renderer/desktop
flows (onboarding, Settings 'Install CLI', feature tips) via the cli:install IPC;
headless serve has no renderer. Run the existing idempotent installer on the
serve startup path, best-effort so a failure never blocks serve startup.

Fixes #5763.

* fix(serve): install a bare `orca` dispatcher on Linux for Claude Team

On Linux CliInstaller installs the CLI as `orca-ide` (to avoid shadowing GNOME
Orca's /usr/bin/orca), not bare `orca`. But the Claude Team launcher (`orca
claude-teams`), composed client-side by both the desktop renderer and the
mobile app and written verbatim to the host PTY, invokes bare `orca` — which
is unresolved in the initial managed terminal (no agent-teams shim on PATH
yet). The serve-mode CLI install therefore fixed macOS but not Linux.

Add a bare-`orca` dispatcher in the serve branch (host-side, Linux only) that
execs the bundled CLI wrapper. ~/.local/bin is hardcoded ahead of /usr/bin on
the managed-terminal PATH, so it resolves. Plain file (not a managed symlink)
so CliInstaller.removeLegacyLinuxCommandIfManaged leaves it untouched.

* Restore ai-vault files to origin/main (drop stale pre-0.52 oxfmt merge artifact)

Co-authored-by: Orca <help@stably.ai>

* Harden serve CLI install: AppImage-safe dispatcher, no-prompt macOS, skip Windows, conflict guard

- Gate CliInstaller.install() to macOS+Linux; Windows would only mutate the
  persistent user-registry PATH without helping the current serve's terminals.
- Inject a non-prompting privilegedRunner so headless macOS never pops an
  osascript admin dialog when /usr/local/bin isn't writable (skips instead).
- Linux dispatcher: exec the stable $APPIMAGE via buildAppImageCliWrapper when
  running from an AppImage (resourcesPath is an ephemeral FUSE mount); skip when
  the bundled orca-ide launcher is absent; never clobber a user-owned ~/.local/bin/orca.
- Single-quote the exec target; add coverage for all new branches.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Daniel Rudaev <daniel@d1dx.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-03 14:12:33 -07:00
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00
Jinjing 6729f683f6 Fix Orca CLI path resolution and improve registration UI (#6484)
* fix Orca CLI path resolution and improve registration UI

- Hydrate the shell PATH before running installer actions on macOS to
  match the user's terminal instead of launchd.
- Resolve the active macOS command path dynamically against shell PATH
  candidates, ignoring broken symlinks and non-executable files.
- Detect and report conflicts with unmanaged executables shadowing the
  configured command path.
- Add "Preparing..." and "Registering..." loading states to the settings
  and setup panels during preflight checks.

* Fix Orca CLI detection and installation path conflict checks

- Allow detecting a managed Orca command located later in PATH when the
  default path entry is missing or non-executable.
- Avoid raising installation conflicts for unmanaged commands located
  after the default install path, as they will be safely shadowed.
- Translate terminal setup and Orca CLI registration strings for
  Spanish, Japanese, Korean, and Chinese locales.

* Only open terminal when pre-install checks succeed

Prevent the terminal from opening and setting the terminal command if
onBeforeOpenTerminal or refreshPreInstallNotice throws an error.
2026-06-27 02:05:23 -07:00
08ba730d8e feat(devin): managed hooks, sleeping resume, AI Vault (#5380)
* Revert "fix(terminal): add proportional scroll fallback for sidebar resize" (#937)

* fix(sidebar): smoothly animate off-screen worktree reveal on click (#1302)

Clicking a worktree card whose row lies outside the sidebar viewport
caused an instant jump when scrolling it into view. Switching
`scrollToIndex` to `behavior: 'smooth'` turns that minimum-distance
scroll into an animated slide while keeping `align: 'auto'` so visible
cards still no-op (no re-centering).

Co-authored-by: Orca <help@stably.ai>

* Avoid local scrollback serialization on shutdown (#1821)

* Fix PR refresh coordinator test arguments (#2545)

* release: v1.4.31

* release: v1.4.31

* release: v1.4.31

* release: v1.4.31

* release: v1.4.31

* release: v1.4.31

* release: v1.4.31

* release: v1.4.36-rc.6

* release: v1.4.36-rc.6

* release: v1.4.36-rc.6

* ci: gate release-cut to the canonical repo so it skips forks (#4815)

The cut job checks out main, bumps package.json's version, and
fast-forwards main. On a fork with Actions enabled, the scheduled RC
cut runs against the fork's main and diverges it on the version line
every slot, so that contributor's PRs back to upstream conflict on
package.json even when their change never touches it.

Gate the job to github.repository == 'stablyai/orca' so it (and the
jobs that depend on it) no-op on forks. Canonical scheduled and manual
cuts are unaffected.

* feat(hooks): install Devin managed status hooks

* feat(devin): address hook review, resume, and UI polish

- Parse Devin config.json as JSONC; warn on read_config_from overlap
- Windows hook command uses forward slashes; APPDATA fallback
- Add devin to sleeping-agent resume and UI registries (plan 003/004)
- Add hook-service and hook-config-json tests

Closes follow-up for plans 002–004 on feat/add-devin-agent.

* feat(devin): scan ATIF transcripts for AI Vault

Register devin in AI_VAULT_AGENTS, discover ~/.local/share/devin/cli/transcripts
(or DEVIN_HOME), parse ATIF JSON sessions, and build devin --resume commands.

* docs(devin): clarify stdin-after-start vs bracketed paste

* fix(devin): use JSONC for remote install, add partial+APPDATA tests

- installRemote: replace readHooksJsonRemote (JSON.parse) with
  readTextFileRemote + parseJsonc for JSONC compatibility on SSH
- Add partial status test (some hooks missing → state:'partial')
- Add Windows APPDATA config path test with fallback

* fix(devin): address CodeRabbit review — sessionId fallback, parseJsonc errors, comment, i18n

* Fix Devin integration edge cases

Co-authored-by: Orca <help@stably.ai>

* Package Devin JSONC parser dependency

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-06-17 20:49:29 -07:00
0ec3882cb8 Add project Windows runtime selection (#5519)
* Add project Windows runtime selection

* Fix project Windows runtime selection

Co-authored-by: Orca <help@stably.ai>

* fix: preserve WSL shell variables

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Neil <neil@stably.ai>
2026-06-17 16:08:14 -07:00