Commit Graph
2191 Commits
Author SHA1 Message Date
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 1cf562deea refactor: split source control AI modules (#16179) 2026-08-25 00:31:06 -07:00
Neil 75103667b0 test(cli): ratchet the exec/fork family, not just spawn (#16390)
The pairing ratchet matched spawn|spawnProcess|spawnSync|runProcess only, so
a resolved CLI handed to execFile was the same unpaired launch with none of
the enforcement. codex-trust-grant-host.ts resolves codex and calls
execFileSync, and escaped the ratchet purely through that omission.

Widen to the exec/fork family. The negative lookbehind keeps method calls
such as `RE.exec(` out, which is what made the bare `exec` name safe to
include; a fixture mutation confirms `/x/.exec('x')` does not trip it, and
adding execFileSync(resolvedCli) to a paired file does.

codex-trust-grant-host is allowlisted rather than changed: its only exec is
a wsl.exe identity probe for the binary stamp, and its actual codex launch
is a CodexAppServerInvocation paired centrally in codex-app-server-session.
The entry records what would invalidate it.
2026-08-24 23:45:51 -07:00
deae7212d9 fix(orchestration): derive inject's agent guidance from the recognized-agent roster (#15874)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: vam <a@a.com>
2026-08-24 23:32:30 -07:00
Neil 48e63c015f refactor agent config and auth services (#16195)
* refactor: split agent config and auth services

* chore: repoint wsl and global-fetch guards at split module paths

* fix: restore merge-base Claude CLI error propagation

Drop the secret-redaction rewriting added to Claude CLI error paths in the
refactor: spawn errors again reject with the original Error (preserving
.code/.errno/.syscall/.stack) and command output/auth-status logs are no
longer rewritten.
2026-08-24 23:15:01 -07:00
Neil 2b1b094aa8 fix(cli): pair every resolved CLI with its runtime, and ratchet it (#16383)
Follow-up to #16365, which paired 8 spawn sites by hand. Hand-pairing is how
the class got introduced, so close it structurally instead.

cliPath is now required on CodexAppServerInvocation, `null` only for the
guest-side wsl.exe launcher where a host path pairs nothing. Optional let a
native builder omit it and silently fall back to pairing against a cmd.exe
wrapper with no type error. Every production site already passed it; only
test fixtures needed updating, which is the type doing its job.

Four more sites now pair. codex-state-db-backfill-recovery spawns the same
`codex app-server` subcommand #16365 fixed elsewhere. cli/handlers/account
was the worst case: addAgentNodePaths prepends the *newest* version-manager
bin, which is not necessarily where the CLI being launched lives, so it
actively created the mismatch — pairing now runs last so the CLI's own node
wins. commit-message-text-generation and skills/skill-update-run spawn
resolved binaries with inherited env.

cli/handlers/skills had grown its own buildNpxPath: a weaker local copy that
prepended unconditionally, ignored the Windows `Path` key, and special-cased
a '.' dirname. Deleted in favor of the shared helper, which checks the
sibling node actually exists — the behavior change one test had pinned.

The ratchet is the point: any file that resolves a CLI and spawns must
reference withCliRuntimeOnPath, with a shrink-only allowlist. It caught
skill-update-run, which I had missed. Its first draft required a call paren
and so let dependency-injected resolvers (`resolveCommand: resolveCodexCommand`)
through — verified by removing a pairing and watching it stay green, then
widened until it failed. A second assertion fails on a stale allowlist entry
so an exemption cannot outlive its reason.

external-editor-launch stays allowlisted: it launches a GUI editor, not a
Node CLI whose ABI matters.
2026-08-24 23:12:37 -07:00
Neil a7505fd911 fix(cli): spawn a version-manager CLI with its own node runtime (#16365)
* fix(cli): spawn a version-manager CLI with its own node runtime

resolveCliCommand falls back to scanning every version-manager install when
PATH misses, so it can hand back ~/.nvm/versions/node/v20.x/bin/codex while
PATH still leads with v22. Nothing paired the binary with the runtime it was
installed against, so its `#!/usr/bin/env node` shebang loaded a v20-built
native module under a v22 ABI and the agent died on first require (#10932).

Reproduced with a real addon rather than asserted: a CLI requiring a
cpu-features build for NODE_MODULE_VERSION 115, spawned with v24 leading
PATH, fails with ERR_DLOPEN_FAILED and exit 1. With the CLI's own bin
directory prepended it runs clean.

withCliRuntimeOnPath prepends the resolved command's directory when that
directory ships a sibling node, and is a no-op otherwise — so a Homebrew or
/usr/local CLI is untouched, and the WSL paths pass a bare `codex`/`claude`
that is not absolute and so never matches.

Host CLI resolution in the Claude login path is now lazy, keeping the WSL
branch from resolving a host binary it never spawns.

* fix(cli): split PATH on the delimiter we join with, pair app-server too

Readiness review findings, all four addressed.

withCliRuntimeOnPath chose its join delimiter from the platform option but
split with the host's. Passing platform:'win32' from a posix host turned
`C:\Windows;C:\Windows\System32` into `C;\Windows;C;\Windows\System32` —
every drive letter torn off at its colon. Latent, since no shipped caller
passes platform, but the sole win32 test was written against the corrupted
value and asserted one split segment, so it green-lit the shredding.

That test's other assertion was vacuous: it seeded only `Path`, so the
`PATH` key it asserted absent could never exist. Deleting the whole
case-dedupe block left the suite green. It now seeds both keys and asserts
the full joined string; removing the block fails it.

Nothing covered the wiring, and the argument choice is the easy thing to get
silently wrong. Note it only diverges on win32 — on posix
getSpawnArgsForWindows returns the CLI itself, so pairing the spawn command
is indistinguishable there. The new test drives the win32 branch with a .cmd
fixture; pairing spawnCmd or dropping the wrapper both fail it now.

codex-trust-grant-host and codex-session-index-heal spawn the same
`codex app-server` subcommand through runCodexAppServerSession and were left
unpaired. Pair centrally there via a new optional cliPath, since
invocation.command may be a cmd.exe wrapper.

Pairing tests live in their own file: adding them inline pushed
codex-fetcher.test.ts past the 800-line ratchet.

* fix(cli): read the Windows path key the child will actually use

Round-2 review finding. The read was narrower than the delete: the key was
picked from exactly two spellings (`Path`, else `PATH`), while the twin
dedupe removed every key whose lowercase form is `path`. A block spelling it
`path` or `pATh` therefore had its value deleted without ever being read,
handing the child a PATH containing only the CLI's own directory — a strictly
worse outcome than not pairing at all.

Win32 resolves env names case-insensitively and object order preserves block
order, so the entry the child reads is the first case-insensitive match. The
repo already encodes that rule in resolvePathEnvKey
(src/main/pty/windows-path-segment-merge.ts); src/shared cannot import from
src/main, so mirror it locally.

Verified by execution across six env shapes: lowercase, mixed-case, Path-only,
PATH-only, both twins, and a PATHEXT control that must not be touched. All
preserve the original PATH; before the fix the first two lost it entirely.
Reverting the selector fails the new test and nothing else.
2026-08-24 22:30:04 -07:00
NeilandSeongho.Bak 4a57cfac9a fix(terminal): give OMP Pi's Windows Shift+Enter encoding (#16376)
OMP wraps Pi's TUI, so Shift+Enter bytes land in a Pi reader that decodes
CSI-u. The omp profile had no `windowsShiftEnterEncoding`, so it fell back
to Esc+CR — which submits instead of inserting a newline (#9703).

This was latent while an OMP pane's stored title could read either "Pi" or
"OMP" depending on which interleaved frame committed first. Pinning the
title to the launch owner (#16373) made it deterministically "OMP", so the
Windows Shift+Enter fallback now always resolves `omp` and always picks the
wrong encoding.

`prime-agent` already carries this entry for the identical reason.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>
2026-08-24 22:27:45 -07:00
Neil e217fdd10f build(orcad): gate orcad's own graph, and prove it loads under plain Node (#16368)
* fix(orcad): close the browser-provider gaps

The providers landed without enforced coverage, so a regression in either path
would have landed silently.

- CI: the external-Chromium integration test was gated on ORCA_BROWSER_EXECUTABLE
  and nothing ever set it, so it skipped forever. It now runs in its own job
  against the runner's Chrome and FAILS when Chrome is absent rather than
  skipping, because an unset variable is exactly how it went uncovered. Timeout
  raised to 120s: a warm run is ~7s but the first launch against an unseeded
  profile took 30s and hit Vitest's default, and CI is always that cold case.
- Electron provider had no test at all. It is the path anyone with the desktop
  app hits.
- Browser unavailability reported one message for four causes, including telling
  an operator to set a variable they had already set.

Fixes a live defect found while covering it: the runtime advertises
browser.tabCreate.known-id.v1 unconditionally, so a web client sends a
provisional page id for a page that does not exist yet — and the sidecar's
generic requestedPageId branch ran require() on it first and threw. Every
known-id create against the Electron provider failed. The adoption logic was
already there; only the ordering was wrong.

Also updates the workflow-parallelism guard, which correctly caught the new job
missing from verify's required-check list, and asserts verify actually reads it.

* build(orcad): gate orcad's own graph, and prove it loads under plain Node

Two gaps the artifact's own comment asked for.

The ratchet measured only orca-runtime + runtime-rpc, but orcad imports ipc/pty
directly to install the PTY controller, so its graph is strictly larger. The gate
could read zero while the shipped artifact regressed. orcad's entry is now a
ratchet entry point, and the baseline stays empty with it included.

orcad cannot join plain-node-entry-guard — that is a rollup plugin keyed on
electron-vite input names, and orcad is an esbuild artifact. But the half that
matters here is the guard's smoke-load: scanning the metafile proves no module
NAMES electron, not that the graph resolves under plain Node. A dynamic require,
a missing native or a top-level throw all pass the scan and fail at runtime.
build-orcad now runs the bundle with a bogus flag and requires the argv rejection
that only a fully loaded graph can produce.

Verified: a bundle that builds but throws on load fails the gate.
2026-08-24 22:21:26 -07:00
NeilandSeongho.Bak 3b6eb03349 fix(terminal): stop OMP tab title flapping between OMP and Pi (#16373)
* fix(terminal): stop OMP tab title flapping between OMP and Pi

OMP wraps Pi, and both share the `pi-compatible` title-identity group. Two
writers publish frames for the same pane under different labels: main's
synthetic spinner injects "<frame> OMP" every 80ms, while the wrapped Pi
harness emits its own "Pi" frames.

`isDecorativeAgentTitleFrameChange` keys on `status:textWithoutSpinner`, so
`working:OMP` and `working:Pi` read as meaningful changes. The alternation
defeated spinner-churn suppression entirely: every 80ms frame committed a
store patch plus a runtime-graph sync, on both the tab-title and
runtime-pane-title paths.

Pin same-group identity frames to the tab's launch owner at both store
choke points, reusing the existing owner-normalization helper already
applied on the sidebar, remote-sync, and mounted-pane paths.

The relabel is scoped to bare identity frames ("⠋ Pi", "Pi ready"); a
semantic session title ("π - <session> - <cwd>") carries text no agent
profile can reproduce and is left untouched, so this does not reintroduce
the generic-label complaint in #16093.

* fix(terminal): scope owner relabel to cross-identity frames

A frame that already names the tab's own agent carries authoritative status
wording, so relabeling it restated bare "Pi" as "Pi ready" and changed a
Pi-owned tab that never flapped. Only relabel when the frame names a
different member of the identity group.

Also fixes the repro suite's types against the project typecheck.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

---------

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>
2026-08-24 22:16:22 -07:00
Neil 09048c63d4 feat(orcad): add headless browser providers (#16193)
* feat(orcad): add headless browser providers

* fix(orcad): merge the duplicate runtime-browser type import
2026-08-24 21:11:45 -07:00
Neil 788575e300 fix(crash-reporting): stop destroying user crash notes (#15252) 2026-08-24 21:03:34 -07:00
Neil b516300b8c refactor agent hook listener modules (#16187) 2026-08-24 20:45:38 -07:00
Neil 50438041a8 fix(rate-limits): safely surface Codex RPC exit reasons (#16023) 2026-08-24 18:19:48 -07:00
Brennan Benson 31562c5b27 fix(windows): attach interactive login children to console input
Verified on native Windows awin at the exact PR head with Electron CDP/Playwright: the Claude sign-in console is visible, cancellation after console launch restores Add Account state, and the login process/PID/temp cleanup completes.
2026-08-24 18:12:42 -07:00
Jinwoo Hong fba910f2ea fix(crash-reporting): scope renderer crash evidence (#16313) 2026-08-24 17:01:18 -07:00
Brennan Benson 723158a519 feat(workspace-cleanup): blockers become labels, not refusals (#16282)
* wip(workspace-cleanup): PR 3 blockers-become-labels, recovered from a dead worker

Uncommitted work recovered from a worker that died with 'Agent process stop was
requested but never confirmed'. Committed as-is to preserve it; NOT verified yet.

* Fix workspace cleanup review regressions

* fix(workspace-cleanup): drop filter chips for the safety fields this PR removes

The cleanup dialog crashed on every open. My merge of #15300 brought in the
applied-filter chips, which read `safety.tiers` and `safety.selectableOnly` --
the exact fields this PR deletes. Electron QA caught it; the chip derivation runs
on open, so it threw before anything rendered.

Removed the two chip branches, their formatter entries, and the now-dead 'tier'
chip-kind label. The test that swept every chip keeps its breadth by using
`safety.dismissed`, which survives.

Worth noting what did not catch this: typecheck flagged only the test file, not
the source, because the QA agent had already patched the source locally without
committing. A merge that compiles can still remove a field a caller reads at
runtime, and only opening the dialog proved it.
2026-08-24 16:26:33 -07:00
Brennan Benson 4f525f17f5 feat(agent-status): add the pane agent identity resolver (#16157)
* feat(agent-status): add the pane agent identity resolver

Four ladders answer "which agent is in this pane" independently — the tab icon, the
open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback — and they
disagree. Two consult the terminal title before the launch record, so a string Orca parsed
outranks a fact Orca owns.

resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering:

1. Evidence is ranked by how directly it observes the process; a display title is last.
2. Each observation carries the runId of the agent run it describes. Evidence from a superseded
   run is INELIGIBLE, not merely outranked.

Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming
B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) —
identical signals, opposite correct answers. Run ids make them different facts: in the bug both
belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as
a test asserting the two produce opposite answers from the same evidence.

Missing run ids are treated as eligible. Absence means "this peer does not publish them", not
"this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so
pane-scoped consumers cannot inherit another pane's agent.

No consumer imports this yet; each migrates separately with its own evidence.

Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the
run filter fails 3.

* fix(agent-status): close three resolver contract holes found in review

**Duplicate evidence of one source resolved by array order.** `eligible.find(...)` returned the
first match, so two live hooks naming different agents were settled by input position — the exact
property this resolver exists to remove. The original order-independence test only used DISTINCT
sources, so it never exercised it. Conflicting same-class evidence now returns null with
`ambiguousAt`, and does NOT fall through to a weaker source: letting a title answer whenever two
hooks disagree is worse than saying nothing.

**A bare numeric runId collided across authority restarts.** `incarnation` is a total order only
within one `authorityId` (agent-status-observation.ts states this), and the id is regenerated per
authority instance, so a restarted host counting from its own floor would report `1` and match an
unrelated live run 1. The run key now carries its authority, and evidence from a DIFFERENT
authority is treated as incomparable — kept, like an absent key — rather than as stale.

**Title stayed reachable by consumers that authorize writes.** Ranking it last makes misuse
unlikely; `minimumSource` makes it impossible. An action consumer passes `'launch'` and weaker
evidence is dropped before ranking, so routing or delivery cannot name a target from a parsed
string even by reordering its inputs. Display surfaces omit it and are unaffected.

Also restores the generic agent-vocabulary parameter, which lives on the routing branch and was
lost when this branch was rebased.

Each fix is mutation-verified: first-match restored fails 3, ignoring authority fails 1, dropping
the floor fails 2. The authority test was itself vacuous on the first attempt — both sides used
`incarnation: 1`, so a resolver ignoring authority still passed on the numeric compare. It now uses
differing incarnations.

The remaining review finding, that `process > launch` has no freshness bound, is NOT fixed here:
it needs an observation timestamp the evidence type does not yet carry. Recorded rather than
silently dropped.
2026-08-24 16:06:13 -07:00
Jinjing b76a47e468 Add requireTuiAgentConfig to validate agent ids (#16310)
Agent ids persist in automations and settings, so they outlive the
build that wrote them. Direct config lookups fail with unclear
"Cannot read properties of undefined" when an id becomes unknown.
This function validates the agent and throws a clear error message
naming the unknown id.
2026-08-24 15:28:59 -07:00
Brennan BensonandNeil ec4687c434 feat(agents): distinguish Claude background monitoring (takes over #14205) (#16201)
* feat(agents): distinguish Claude background monitoring

Adds an optional `workingMode: 'monitoring'` discriminator for a Claude
session whose lead turn finished but which still has background shell tasks
or session crons registered. The wire state stays `working`, so older peers
that never read the field keep rendering Working.

(cherry picked from commit d5d54b4bdd)

Rebased onto current main (554 commits of drift) by Brennan Benson;
conflicts resolved by keeping both sides where main and this branch made
independent additions to the same construct.

* fix(sidebar): keep monitoring status visible

(cherry picked from commit fd6b38654d)

* test(agents): cover Claude monitoring drain

(cherry picked from commit ce4d61ebf8)

* test(mobile): avoid unresolved renderer test type

(cherry picked from commit bbcfa35ff9)

* feat(agents): render Claude monitoring as a static turquoise dot

Replaces the yellow Radio glyph from #14205 with a static dot in a new
--agent-monitoring token (#8abeb7), defined once for light and once for
dark like --workspace-status-done, so the status keeps its identity when
the theme flips. Deliberately a fixed UI value: it never reads terminal
theme state at runtime.

Adds the turn-boundary notification pins. The monitoring predicate and
the turnCompletedAt stamp are computed from the same "lead said done but
the pane resolves to working" expression, so a rename can silently drop
the stamp and kill a completion notification that works today with
nothing else going red.

* revert(agents): restore the yellow Radio glyph for monitoring

Brennan chose #14205's original treatment over the turquoise dot, so the visual
goes back to nwparker's: lucide Radio in text-yellow-500 across the sidebar,
dashboard dot, cmd-j palette and agent-map ring.

Reverts only the visual surface. The turn-boundary notification pins stay — the
monitoring predicate and the turnCompletedAt stamp share an expression, so a
rename can silently drop the stamp and kill a completion that works today with
nothing else going red. The --agent-monitoring token is removed with its last
consumer rather than left dead in main.css.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-24 15:24:32 -07:00
Brennan Benson 526120687d feat(agent-status): add an order-independent title evidence parser (#16148)
* feat(agent-status): add an order-independent title evidence parser

The chain this will replace is a first-match-wins scan of substring predicates, so its answer
is decided by list position rather than by how strong the evidence is. Four real recorded Grok
panes read as Codex today because Codex is checked first and their task text mentions it.
Reordering cannot fix that: whichever branch is hoisted, some other pair breaks.

collectAgentTitleEvidence collects every signal first and ranks by class afterwards:

  vendor marker  — a sigil or control sequence the agent itself emits; task text cannot forge it
  anchored name  — a name where a grammar reserves the position for identity (Orca's `- <agent>`
                   owner suffix, or the whole undecorated remainder)
  free-text name — a name anywhere else

Anchored beats vendor marker, so `✳ agy` is Antigravity rather than Claude. Free text never
beats either, and never becomes identity on its own even as the only name present — an absent
icon is recoverable, a confidently wrong one is not. Conflicts within a class resolve to null.

No consumer imports this yet. Swapping getAgentLabel in place would move ~20 call sites at
once; each migrates behind the resolver, with its own evidence.

Measured against the live recorded-title corpus (747 distinct titles), 598 agree with the
current chain and 149 differ:

  144  claude -> null   spinner-only titles. Braille and quarter-circle frames are emitted by
                        many agents, so they prove the pane is busy and nothing about who it
                        is. These are answered by stronger signals once the resolver lands,
                        which is why the parser is not wired up on its own.
    4  codex  -> grok   the misattributed Grok panes, fixed.
    1  codex  -> null   a spinner-prefixed Claude pane whose task text names Codex.

Three parser defects were found and fixed by reviewing that delta rather than reasoning about
it: the OpenCode envelope was treated as a vetoable marker instead of an owning grammar, free
text was allowed to veto a vendor marker (which blinded 13 real Claude titles), and the owner
suffix matched the tail of a hyphenated worktree name (`review-14600-codex`).

* fix(agent-status): harden title evidence collection

* fix(agent-status): limit emitted display evidence

* fix(agent-status): anchor explicit title evidence

* test(agent-status): cover anchored token filtering

* fix(agent-status): complete explicit marker evidence

* fix(agent-status): recognize reserved owner ids

* fix(agent-status): reject cwd path titles

* fix(agent-status): avoid bare-name identity guesses

* fix(agent-status): require spinner for working labels

* fix(agent-status): honor opted-out synthetic profiles

* fix(agent-status): harden wrapper evidence boundaries
2026-08-24 13:12:10 -07:00
Brennan Benson 1921ba2250 fix(agent-status): clear the pane when a Claude compact finishes (STA-2915, STA-4613) (#15202)
* fix(agent-status): clear the pane when a Claude compact finishes (STA-2915, STA-4613)

A manual /compact ends at an idle prompt without emitting Stop, so nothing in the
compact window could ever clear the pane. A worktree that entered the compact
`working` stayed `working` until the 30-minute stale sweep -- and the summarizer's
start-less SubagentStop kept republishing the row, resetting that clock each time.

The correlation added by #12332 was supposed to own this, but it could never run:
PreCompact and PostCompact were never added to CLAUDE_EVENTS, so they were never
registered with Claude. compactTrigger was always undefined, and the transition
guard, the ownership cache, the relay wire field and the ingest branch were all
unreachable. Five test files exercised the logic by injecting events past the
registration boundary, so the suite stayed green over code that could not execute.

Register PostCompact -- and deliberately NOT PreCompact. Measured on Claude Code
2.1.227, a successful manual compact emits PreCompact, a start-less SubagentStop,
SessionStart(source=compact), then PostCompact; an ABORTED compact ("Not enough
messages to compact") emits PreCompact ALONE. Mapping PreCompact to `working`
would strand the pane on every aborted compact, which is the bug being fixed, so
the abort guard is structural: Orca never subscribes to the pre-validation event.

PostCompact carries its own trigger, so no anchor is needed to tell manual from
auto and the correlation machinery is deleted rather than repaired. Manual becomes
a `done` with sessionBoundary set -- a finished compact is a session-shaped
boundary, not a completed turn, so completion notifications, unread counts and
automation-run evidence stay out of it. Auto claims nothing: it runs inside a turn
that resumes and emits its own Stop.

The source-blind early return that dropped compact events for EVERY provider
before its normalizer ran is narrowed to Claude, so it keeps failing closed on a
malformed payload without pre-empting other providers.

Ownership is kept where the deleted guard had it: a valid provider prompt id is
required, a completion clears a row but never creates one (a retired pane must not
be resurrected), and a hydrated row is matched on provider session only -- it
carries the previous session's connectionId, and older rows carry no session at
all, so a strict check would reject the restart case this fixes. A consumed
prompt id keeps relay duplicates from refreshing the row.

Mixed versions: no new wire field and no new opcode. An older relay normalizes
with its own shipped mapping and forwards the event, so ingest drops `auto`
envelopes and stamps the boundary on `manual` ones; its replay strips the trigger
entirely, so payload state stands in for it while ownership is still enforced. The
relay now caches a completion with its compact identity removed, so a client that
was offline during the compact still receives the clearing row on reconnect.

Tests go red before this change and green after: 6 of 12 in the new
registration-gated suite and 5 of 8 in the relay/ingest suite. The harness delivers
only events present in CLAUDE_EVENTS, so a fix that is never registered cannot
pass -- the failure mode that let the original correlation ship unreachable.

* test(agent-status): restate the compact reliability gate around the new invariant

The gate pinned a test file this change deletes, so the manifest check failed.
Repointing the path alone would have left the gate describing an invariant that
no longer exists: it required a manual PostCompact to match its exact PreCompact
generation, and PreCompact is no longer consumed at all.

Restate it. The invariant is now that PreCompact never moves a pane, that only a
manual PostCompact marks done and does so as a session boundary, that a
completion clears an existing row but never creates one, and that a relay
predating the contract has its automatic envelopes dropped and its trigger-
stripped replays classified by payload state under the same ownership checks.

Evidence runs are the real ones: the 105-test suite from this branch, and the
Claude Code 2.1.227 PTY capture that measured PreCompact arriving alone on an
aborted compact.

* fix(agent-status): clear the restart-stuck pane a compact was meant to clear

Review found the completion did not clear the pane STA-2915 actually reports, and
that republishing it was a strict regression.

- A manual completion now retires a subagent that exists only as a disk snapshot:
  a /compact only completes at an idle prompt, so a restored child is proof of
  nothing. Live evidence -- a child observed in this runtime, an unclassifiable
  running background task, a registered session cron -- still holds the pane.
- A completion that cannot clear now publishes nothing instead of restating the
  row, which was stripping restoredUnconfirmed off a hydrated row and restarting
  the staleness clock for work the compact never observed.
- The relay defers compact ownership to the client that owns pane identity, so a
  cold relay cache can no longer swallow the one event that clears a remote pane.
- claudeConsumedCompactPromptIdByPaneKey joins all three pane-scoped teardown
  routes, and an auto compact no longer spends the pane's consumed-compact slot.
- The promptless completion keeps the summarized turn's label with or without a
  trigger on the envelope.

Tests: the two restart cases now deliver the completion while the hydrated row is
still cached, so they exercise the restored-row branch instead of passing through
the strict one; the triggerless working replay is asserted from a FINISHED pane so
it can fail. Reverting the four source files turns 12 of 21 registration-gated and
8 of 12 relay/ingest tests red, and 18 of 18 targeted mutations are caught.

* fix(agent-hooks): preserve compact identity across relay replay

* docs(reliability): describe compact replay ownership
2026-08-24 13:06:44 -07:00
Brennan Benson 43461fca46 fix(ai-vault): replace scanner internals with actionable panel copy (#16229)
* fix(ai-vault): replace scanner internals with actionable panel copy

Agent Session History painted the scanner's supervision errors verbatim:
"AI Vault service restart circuit is open." and "AI Vault service timed
out after 130000ms." Neither tells a user what happened or what to do.

Add a shared mapper that rewrites the supervision family into copy tied
to an action, passing anything unrecognized through so scanner-authored
messages (host name, remote path, cap) keep their own wording. It also
strips Electron's `Error invoking remote method` wrapper, which this
path never handled. Applied at both surfaces the panel paints: the local
leg's scan-issue row and the thrown-rejection banner. Humanizing in main
covers remote clients too; the raw text moves to the main log.

Also make "Refresh to try again" true. The relay path lets a forced
refresh reopen the restart circuit, but on the local path `force` stopped
at the cache layer and never reached the supervisor, so the refresh
button was inert for the full 60s fault window.

The client file sat at 299/300 lines, so extract the child-listener and
init-frame wiring into `attachAiVaultServiceChild`, next to the
ready-waiter and retirement helpers it belongs with, rather than bumping
the max-lines ceiling.

* fix(ai-vault): humanize runtime scanner errors

* fix(ai-vault): preserve runtime error metadata

* fix(ai-vault): normalize wrapped scan errors

* fix(ai-vault): preserve non-scanner relay errors

* fix(ai-vault): make forced retries cancel backoff
2026-08-24 13:00:03 -07:00
Jinjing 7e76bb3aec Fix rebase race by fetching to private ref before rebasing (#15990)
* Fix rebase race by fetching to private ref before rebasing

`git pull --rebase` is vulnerable to concurrent fetches modifying remote-tracking refs during execution. Fetch to a temporary private ref (refs/orca/rebase/*) first, then rebase from that stable ref to avoid the race condition.

* Fix rebase race by fetching to private ref with timeout

Concurrent fetches can interfere with remote-tracking refs between
fetch and rebase. Use a unique private ref and 60-second timeout to
isolate each rebase operation and prevent hangs on stalled remotes.
Extract gitPullRebaseFromBase to a dedicated module.

* fix rebase race by fetching to private ref with timeouts

Concurrent fetches can replace FETCH_HEAD and remote-tracking refs between
fetch and rebase, causing the rebase to fail. Fetch to a temporary private
ref instead, use --no-write-fetch-head when available (Git 2.29+), and
serialize FETCH_HEAD access for older versions. Add process termination
barriers to ensure proper cleanup and extend timeouts for SSH operations.

* Fix rebase race by fetching to both private and tracking refs

Concurrent fetches between source and rebase can replace remote-tracking refs,
causing rebases to use stale bases. Now fetch to both a private ref and the
remote-tracking ref simultaneously, ensuring the tracking ref stays current.

Also improves process termination for WSL guests with process-group tracking,
fixes process-tree termination timeouts on POSIX, and serializes FETCH_HEAD
operations for linked worktrees through their shared Git directory.

* Add WSL setsid --wait probe and barrier termination timeout

Probe for `setsid --wait` support and fall back to unwrapped execution for BusyBox compatibility. Add a deadline for process termination barriers to prevent hanging when tree termination cannot be verified. Update tests for cross-platform compatibility.

* Add wsl-process-group-termination to WSL invocation allowlist

* Serialize per-worktree git mutations to fix rebase race

Introduce operation locking for each worktree to prevent concurrent
mutations (like rebase) from interfering with each other. Ensures
rebasing a linked worktree doesn't affect the source worktree state.
Add SIGKILL fallback if process termination barriers cannot verify
tree termination.

* Serialize pull and fastForward operations per-worktree

- Extract generic git operation lock to reuse locking pattern
- Refactor existing locks to use the generic implementation
- Apply per-worktree serialization to pull and fastForward to prevent races

* Route WSL group termination through runWslProcess

ce743a4fd0 silenced the wsl-invocation boundary guard by appending
wsl-process-group-termination.ts to the allowlist. That fixture only
grows when the scanner learns to see a spawn it was blind to, and only
shrinks for a migration -- this was new code on this branch, so the
entry was the boundary regressing rather than the guard getting honest.

Migrate the kill instead. terminate() now calls runWslProcess with the
script form (`<shell> -c <script> -- <args>`), which keeps the group id
in $1, so the payload is unchanged. The script is plain POSIX, so it
must not pin shell: 'bash'; it calls only builtins and coreutils on the
default PATH and reads no login environment, so loginPath is 'none'.

wrapGuestArgs() is untouched: its argv is spliced into git/runner.ts's
own wsl.exe invocation, which is a long-standing allowlist entry.

The unit test now mocks runWslProcess and asserts the spec shape --
distro, loginPath, the group id in args -- so a regression back to a raw
spawn fails here as well as at the boundary guard.

* Assert cleanup is defined before accessing properties
2026-08-24 12:11:55 -07:00
Brennan Benson 9d87532ecf feat(workspace-cleanup): name every applied filter in the bar and make it removable (#15300)
* feat(workspace-cleanup): name every applied filter in the bar and make it removable

Replaces the one-time filter migration this PR used to carry, and the per-group
apply checkboxes that were planned to follow it. Both existed to answer one
question -- why is a filter I never turned on hiding my workspaces -- and neither
was the cheapest honest answer.

The bar already read "Showing 546 of 799", so the *effect* was always visible.
What was missing was the *cause*: which filter, and that it came from a previous
session. Active constraints now render as removable chips in the bar, and Clear
filters is promoted out of the popover it was buried in.

Why this replaces the migration: a blanket clear cannot tell a wheel mutation
from a deliberate choice, and the version here was worse than that -- it
neutralized all ten groups, including location.repoIds and location.pathPrefix,
which a wheel cannot set. With a chip, a stray threshold is visible on open and
one click removes it. No marker, no provenance guessing, nobody's deliberate
filters deleted.

Why this replaces the apply toggle: every group already has a neutral resting
state that does not constrain -- an empty numeric field, tri-states at 'any',
the two booleans permissive. "Not applied" and "empty" are the same state
today, so the toggle's only unique power was parking a value you are not using.
That is a modest convenience against ten checkboxes, two-way drafts, a
persistence model that could not use an 'enabled' flag without an older host
dropping it, and a hydration guard.

Chips are per-field, not per-group: "Activity" tells a reader nothing, while
"Idle 20d+" names the thing hiding their workspaces.

Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a
0 maximum hides every measured non-empty workspace and does.

* fix(workspace-cleanup): address the second review on the filter chips

Four findings from the re-review of 3b88645aa1:

- **Same-tick writes could restore a cleared chip.** `replaceFilters` read the
  store but `patchFilters` still rebuilt from the render snapshot, so a chip
  clear plus a facet patch in one tick left `idleMinDays` at 20. Every writer
  now derives from current store state. Tests cover both call orders, and
  reverting the reader reproduces the reported failure.
- **Chip labels went stale across a language change.** They were memoized on
  `filters` alone, so unchanged filters reused the previous language's strings.
  Derivation is constant-size (one pass over the filter fields, not per row), so
  it just runs each render.
- **The new catalog entries were English-only.** ko and zh now carry all 22 chip
  strings. The verifier stayed green because missing target-locale entries are
  allowed and fall back to English -- which is exactly the trap this series has
  now hit twice.
- **Remove targets were 16x16.** They use the shared button primitive at the
  canonical `icon-xs` size.

Also trimmed the defect-history comments to the repo's concise style.

* fix(workspace-cleanup): unify chip clears on the merged updater form

#15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the
same idiom rather than reading the store directly. Same guarantee, one pattern.
2026-08-24 11:42:01 -07:00
Jinjing aa32871a61 Improve cmd j ranking with recency (#16281)
* Track container-only tokens and tab focus for cmd+j ranking

Previously ranked by whether any container-only matches existed (boolean);
now counts tokens matching only containers for finer-grained ranking. Tab
focus recency is now tracked explicitly so recent refocuses rank above
stale worktree activity. Preserves worktree grouping by input order while
applying focused-group MRU within each block.

* fix(cmd-j): preserve duplicate recent tab occurrences

* fix(cmd-j): preserve host scope during worktree purge

* fix(cmd-j): scope repo purge for exact-id host twins

* fix(cmd-j): scope ssh visit recency to local to survive restarts

Boot hydration loads only local + runtime:* partitions, so routing
ssh-qualified recency to ssh partitions strands it across restarts.

- Keep ssh-qualified visit timestamps in local partition
- Route runtime-qualified keys to their partition
- Remove groupId from recent tab occurrence base (unstable on regroup)
- Collapse bare and host-qualified timestamps, preserving max
- Simplify repo pruning host-match logic
- Add robustness: optional chaining, helper function

* Scope focused tab recency by worktree to fix Cmd+J ranking

Tab ids can be duplicated across worktrees; scoping recency keys to per-worktree prevents one worktree's MRU position from overwriting another's in Cmd+J. Scope worktree order blocks to (hostId, worktreeId) to keep same-id worktrees on different hosts separate.

Also fix recency preservation during partial identity migrations and prune orphaned host keys on removal.
2026-08-24 11:23:16 -07:00
Brennan Benson 94f231737d fix(agent-status): retire panes whose agent process is gone (STA-4612) (#15212)
* fix(agent-status): retire panes whose agent process is gone (STA-4612)

Agent status can hold `working` on a pane where no work is outstanding, and
nothing closes the gap. A pane's Claude state is a join of a lead turn and three
latches — the subagent roster, the background-task gate and the session-cron gate
— and each is set by a hook and cleared only by another hook. Claude Code emits
no terminating hook on `/exit`, `/clear`, Ctrl+C, crash, SIGKILL or terminal
close, so every one of those latches is a claim with no owner and no expiry. The
join is also materialised at ingest time and persisted, so a stale `working`
survives restart and blocks hibernation, which requires `done`.

Registering `SessionEnd` is not the fix: it covers roughly a third of exit paths
(measured on 2.1.231/2.1.233; upstream anthropics/claude-code#17885 and #6428 are
both closed as not planned). Nor is a TTL — `AGENT_STATUS_STALE_AFTER_MS` only
decays the sidebar dot at read time while the stored row stays non-terminal.

So the backstop is built from evidence Orca already owns.

A session id that changes means the conversation was replaced. On the first hook
of the new session — whatever that hook is — the previous session's own claims
are void: its session crons and its one-shot subagents. Deliberately not voided:
the background-task gate (a background shell is an OS process that survives
`/clear`, and the previous inventory is positive evidence it was running), and
`confirmedTeammate` rows (persistent in-process teammates a lead swap cannot
end). The lead record is left to the incoming event's own fold.

A certified process exit retires the pane. Orca already does this on every
attributable PTY exit — `clearProviderPtyState` resolves the pane key and calls
`clearPaneState` — but that resolution depends on the spawn-time `ptyPaneKey`
mapping, which a restored or reattached PTY may never rebuild. Those panes keep
their row and latches for good. `onPtyExit` knows the keys teardown could not
resolve, so it reconciles them from its own records. The certificate is
`exitCode >= 0 || hostExitConfirmed || providerExitObserved`: a synthetic `-1`
from a failed stop is not a death (the PTY can have survived it), while a real
exit can also report `-1`, so neither the code nor the SSH surface predicate is
sufficient alone. `providerExitObserved` is additive and separate from
`hostExitConfirmed`, which also drives the liveness verdict and the SSH surface
decision.

A confirmed shell foreground is the `/exit` case: the agent died, the shell
lived. That already dropped the row, but through `agentStatus:drop`, which by its
own contract preserves a live pane's caches — so every latch survived and the
next event resolved the pane back to `working`. It now routes through the
reconciler instead, gated on a per-pane accepted-status generation rather than
row identity: the confirming process read can take seconds, and `updatedAt`
cannot order two writes inside one millisecond (the store deliberately admits
equal timestamps).

Cold start generalises the same way. The startup sweep required a restored
subagent roster, so a stranded lead row, background-task gate or cron gate — the
shapes with no child event left to reap them — were never candidates.

Hibernation needs no change: with the above, those rows become genuinely `done`
and the lockout resolves through the front door. A `restoredUnconfirmed` bypass
in the planner would let it reclaim the heap of an agent that may be working.

Not included: folding `background_tasks` from a child-attributed `SubagentStop`.
Writing its test surfaced #11838's deliberate assertion that child inventories
are not authoritative for lead-owned background work, and the listener says the
same — "background_tasks is trusted only where unambiguous". An empty list on a
`SubagentStop` does not prove the lead's shell ended, so the fold would have
cleared a gate on evidence that establishes nothing.

STA-4119's live-side question — whether a genuinely live background shell should
hold the lead row after the lead turn ends — is untouched. This change extends
gate-clearing to zero new triggers.

* fix(agent-status): make the confirmed-shell reconcile survive its own drop

The /exit leg never fired. `settleDeferredCommandFinishedStatusDrop` runs the
paired drop before the reconcile, and `dropAgentStatus` cleared the per-pane
accepted-status counter the reconcile's guard then read — so the guard compared
a live anchor against a zeroed counter and skipped itself on every pane that had
a status row, which is every pane worth reconciling. The existing test passed
only because it used a pane with no row, where the drop early-returns and both
sides read 0.

Stop keying the guard on a counter a sibling teardown path can reset: the
ordinal is now stamped on the row itself, derived from the row it replaces, so
there is no side table to clear and a batched burst lands the same ordinals as
the equivalent sequential writes. A removed row means "nothing reported", which
is exactly what the paired drop leaves behind.

Also:
- Keep the `providerSessionOnly` resume identity that the paired dismissal mints
  when the shell outlived the agent; a certified PTY exit still takes it, since
  there is no pane left to resume into.
- De-vacuum two guard tests. The confirmed-teammate pin never anchored a session
  owner, so the void it claimed to survive never ran; the unavailable-inspection
  pin asserted before the confirm ladder settled. Both now fail when their guard
  is removed.
- Derive `hasLiveClaimsForPaneKey` from a predicate that lives beside
  `clearPaneCacheState`, so a new latch cannot be added to the teardown and
  silently missed by the claim check.
- Drop the unreachable compact-`trigger` clauses; SessionStart is the whole guard.
- Cover the connectionId arm of the exit certificate, where a provider-observed
  death and a preserved SSH surface are deliberately independent.

* fix(agent-status): keep agent-status-types under its line cap

main already sits exactly at the 300-line max-lines cap for this file, so the single
`acceptedStatusSeq` field this branch adds pushed it to 301 once main's observation
facet merged in.

Declared the field as a mixin beside the observation facet instead. Both are per-write
facets mixed into `AgentStatusEntry` rather than fields a reporter supplies, so they
belong together — and the capped file loses a line rather than gaining one, since it
already imports from that module. No lint suppression.

* fix(agent-status): collapse the entry facets into one intersection

The previous attempt still tripped max-lines: two mixins on one intersection wrap
across two lines under oxfmt, so removing the field line bought nothing.

Expose a single AgentStatusRowFacets that already includes the observation facet, so
the entry intersects one short name on one line. The payload keeps intersecting the
observation facet alone — it must not carry the renderer-local ordinal.

Verified by formatting first and then linting, which is the order that catches this.

* fix(agent-status): retire resume authority with dead panes
2026-08-24 11:15:11 -07:00
JinjingandBrennan Benson 7b9529da22 Add keyboard shortcut for workspace deletion (#16271)
* Add keyboard shortcut for workspace deletion

Default Mod+Shift+Backspace (⌘⇧⌫ on Mac) lets users delete the hovered
worktree or folder workspace immediately. The shortcut targets the
sidebar hover state rather than requiring focus, and avoids terminal
pane D-based split shortcuts on all platforms.

Co-authored-by: Brennan Benson <brennankbenson@gmail.com>

* Omit delete shortcut from disabled Delete Worktree for primary checkout

- Remove shortcut badge from the disabled "Delete Worktree" action when it cannot be executed
- Only show shortcut in multi-context delete actions where the command is available
- Extract host identity parsing into reusable helper function to prevent inline string manipulation
- Fix folder workspace deletion to use correct host-qualified identity comparison

* Document host extraction safety for destructive worktree ops

Unqualified identities must stay undefined rather than defaulting to
'local'. Destructive operations depend on correct host identification.
Added tests and JSDoc to clarify this safety-critical behavior.

* fix test

---------

Co-authored-by: Brennan Benson <brennankbenson@gmail.com>
2026-08-24 10:12:38 -07:00
Neil 95633a7883 Fix stale task-source flashes in new workspace input (#16145)
* fix(new-workspace): prevent stale GitHub URL selection

* fix(new-workspace): guard all task URL transitions

* test(e2e): make task URL frame proof runner-safe

* fix(new-workspace): guard Enter during task URL lookup
2026-08-23 22:18:39 -07:00
Brennan Benson 55258f34ad test(agent-status): characterize title-derived agent identity before the resolver change (#16144)
* test(agent-status): characterize title-derived agent identity before the resolver change

getAgentLabel is an ordered first-match-wins scan of substring predicates over a display
title, so chain position rather than evidence strength decides identity. Pin the current
answers — including the wrong ones — so the resolver change lands as a reviewable diff of
assertions instead of silent behavior drift.

Eight of the nineteen assertions record defects. Five are minimized from real recorded pane
titles: four Grok panes that read as Codex and one that reads as Gemini CLI, in every case
because a foreign agent name in free-form task text is checked before the `- <agent>` owner
suffix that actually names the pane. The suite also pins the pairwise property behind them —
both orderings of a name pair resolve to the same agent, which is the tell that the title
carries no signal distinguishing them.

Also pinned as correct so the resolver does not regress them: hyphenated worktree names
(`review-14600-codex`) stay unclassified, and a Claude glyph still wins over foreign task text.

Verified non-vacuous: applying PR #15535's narrowing to isGeminiTerminalTitle flips exactly
four assertions, one of them a real corpus title, and the suite is green again on revert.

No production code changes.

* test(agent-status): re-pin the four assertions #15535 changed

#15535 landed the Antigravity narrowing, so four characterized answers moved. Re-pinned
against the new main rather than deleted, and the two that are now correct say why they are
correct — a targeted exception cleared the path, not a structural fix.

Added the general form as a new defect case: the same Grok pane without the word
"Antigravity" in its task text still reads as Gemini CLI, because only that one pair has an
exception. That is the case the resolver has to answer without a per-competitor clause.

* test(agent-status): clarify characterization precedence
2026-08-23 19:07:38 -07:00
Neil 0753f0a8dc fix(crash-reporting): stamp React #185 boundary attribution as unreliable (#16153) 2026-08-23 15:56:27 -07:00
Neil 0926c25854 refactor: reuse canonical regex escaping (#16150) 2026-08-23 15:45:53 -07:00
Brennan Benson ab3b1d07cd reland(opencode): session continuity without the command-finished deferral (STA-4557) (#15350)
* reland(opencode): session continuity without the command-finished deferral (STA-4557)

Relands #14866 (reverted in #14943) minus its `orca-runtime.ts` change, which
is what caused the revert.

## Why the original runtime change was wrong

`retirePtyAgentLaunchAuthorityAfterCommandFinished` deferred launch-authority
retirement behind an async foreground read, on the premise that OpenCode emits
`command-finished` while still in the foreground. Raw PTY capture disproves it:
OpenCode emits no OSC 133 of its own, and Orca's shell wrappers emit exactly one
`133;D` per pane — at OpenCode's exit — under both zsh and bash. The event being
deferred past only ever fires at exit, which is exactly when authority should be
retired. Both call sites stay on the synchronous `retirePtyAgentLaunchAuthority`.

## Why the deferral was unsafe

`confirmPtyAgentExit` uses the same async-foreground pattern four lines away, but
its early return means "don't record an exit" — conservative. The deferral copied
that shape into a site where the early return means "don't revoke a secret". Same
code, inverted consequence: every guard failed open, so a stale or racing read
silently kept a finished session's authority alive, and the pane's persisted
`launchTokenHash` was never scrubbed — so it rehydrated as `restored` authority
after an app restart.

## Why the deferral's guards could not have worked

`ORCA_AGENT_LAUNCH_TOKEN` lives in the PTY environment, so every process started
in that shell inherits it — both sessions in a reused pane post the same token. A
pane-lifetime bearer secret cannot be a session identity baseline, by
construction, and `incarnationId` tracks the PTY, not the agent. The only field
that separates sessions is the provider `sessionID`.

## What lands

- Status/session-boundary work from #14866: opencode emits `SessionStart` for
  root sessions (mimo-code does not), launch-token fencing, and `SessionStart`
  as an opencode turn boundary.
- The two `server.ts` fixes from #14941: re-fence a still-authorized pane on a
  tokened `SessionStart`, and restore mimo-code's explicit-prompt restart
  boundary (mimo emits no `SessionStart`, so opencode-only stranded its panes).
  #14941's re-poll hunk is dropped along with the code it patched.
- Five regression tests in `opencode-finished-session-authority.test.ts`. They
  pass here and all five go red if the deferral is re-added.

* chore: drop incidental reformatting of files unrelated to this PR
2026-08-23 15:24:25 -07:00
Brennan Benson da57e10dd9 fix(agent-status): stop an Antigravity pane reading as Gemini CLI (#15535)
Antigravity's models are named "Gemini <n.n> <Name>" — the real `agy models`
output is already parsed in commit-message-agent-spec.test.ts — so an agy pane's
own title carries a whole `gemini` token. getAgentLabel checks Gemini CLI before
Antigravity, first match wins, so the model name won and the pane read as Gemini
CLI. Measured on '⠋ agy · Gemini 3.7 Flash · high': geminiGlyphs false,
geminiToken true, agyToken true, label 'Gemini CLI'. Even
'Antigravity · Gemini 3.7 Flash' resolved to Gemini CLI.

This surfaced as the tab bar and the sidebar disagreeing about the same pane,
because the two reach different copies of the chain and apply different
precedence to its result.

Defer only the token path: if a title carries an agy/antigravity token, the
bare-`gemini` branch declines. The four Gemini OSC glyphs stay decisive, and agy
emits none of them. Same shape as the existing isPiAgentTitle veto directly
above, which exists because substring matching made paths like 'gemini-project'
masquerade as Gemini CLI.

Narrowing the token rather than reordering the chain, deliberately: a real
recorded pane title from local terminal history is
'STA-4011 Linux Antigravity Commit Messages - grok' — a Grok pane whose task
text contains the token Antigravity. It resolves correctly only because grok is
checked before antigravity, so hoisting the Antigravity branch would break it.
That title ships as a regression case.

Both copies of the chain are fixed; the sidebar reaches one and the tab the
other, so fixing one alone would only move the disagreement.
2026-08-23 14:40:42 -07:00
Brennan Benson 677718c4a5 fix(codex): stop rebuilding shared Codex state from a read that failed (STA-4823) (#15417)
* fix(codex): stop rebuilding shared Codex state from a read that failed (STA-4823)

Six shared files were rebuilt, erased or reported healthy after a read that had
only failed. Batch A of the STA-4606 split: every one of these is reachable and
testable on the host lane, so none of them wait on the WSL work.

- `config-toml-trust.ts` upsertHookTrustEntries: `existsSync` reported a locked
  config.toml as absent, so the base content became '' and the upsert rewrote
  the file from the trust entries alone — a trust-only stub, with the user's
  model, provider, MCP servers, approvals and comments gone. It refuses now;
  every hook-service caller already turns that into "trust entries could not be
  written. Run /hooks in Codex to approve."
- `codex-trust-grant-ledger.ts`: an unreadable ledger degraded to empty and the
  next write persisted a file holding only the home being written, dropping
  every other home's grants. The write paths refuse; the read path still
  degrades, and a corrupt ledger is still rebuilt.
- `codex-pane-account-registry.ts`: an unreadable registry erased every pane's
  attribution AND cached that erasure, so it survived the file recovering. The
  failure is no longer cached, and both write sites refuse rather than persist a
  registry derived from an empty stand-in.
- `hooks-json-read.ts`: the read arm already separated "no hooks" from "could
  not read", but the `existsSync` arm in front of it returned a valid empty
  config for a file that could not be opened. One read now classifies both.
- `config-settings-baseline.ts`: absent, unparseable and unreadable all collapsed
  into `null`, so the snapshot rebuilt a baseline it could not read — recording
  an in-Codex edit as Orca's own write, after which promotion skips it forever.
- `config-sync-stall.ts`: an unreadable runtime config read as absent and the
  status reported `synced` while the mirror was refusing. It reports
  `managed-home-unavailable`, the existing reason for exactly this, rather than
  borrowing a source-side one and blaming the wrong path.

Absent and malformed still rebuild throughout — resetting corrupt state is the
intent, and conflating it with unreadable would wedge a user on a broken file.

* fix(codex): close shared state read-denial gaps

* fix(codex): recover oversized settings baselines

* fix(codex): name the stalled managed config

* fix(codex): preserve hooks after failed source reads

* test(codex): correct what the denyExistence rig actually models

MEASURED on both platforms: a file-permission denial leaves existsSync TRUE
and fails only the content read — chmod 000 gives EACCES on macOS, icacls
/deny (R) gives EPERM errno -4048 on Windows, with stat/lstat succeeding in
both. The rig's docblock claimed this mode modelled that denial. It does not.

What it models is the UNC / \\wsl$ transport, where an unreachable distro
reports errno UNKNOWN at every level and existsSync folds it to false.

The distinction decides what the D29 guard is worth: under a permission denial
the pre-fix code already failed safe, because existsSync was true so it took
the read branch and threw. Only a transport that lies about existence reaches
the rebuild-from-empty path. No behaviour change; the comment was wrong, not
the code.

* test(codex): exercise live baseline read denial

* fix(codex): retry pane attribution writes

* fix(codex): retry reconciliation registry writes

* fix(codex): report unreadable sync baselines
2026-08-23 13:52:18 -07:00
Neil c3a1694b1d perf(preflight): read the WSL mount table once, and make launch agree with detection (#16053)
* perf(preflight): read the WSL mount table once per shell, not once per CLI

The prelude is embedded in the lookup script, and the caller wraps that in
`for cmd in <every agent>`, so the unconditional assignment forked awk once per
probed CLI -- 36 of them inside the distro against a 10s detection budget. The
comment claimed it was read once outside the loop; it was not.

`${x+set}` rather than `[ -n ... ]`: a host with no Windows mounts yields the
empty string, which must still count as read.

Pinned by counting real awk forks through /bin/sh with a stub on PATH, because
nothing covered this expression at all -- a wrong-field mutation shipped green.
Verified to bind: the unconditional form counts 4 for 4 commands.

* fix(wsl): make launch resolve the same binary detection reported

Agent detection skips Windows mounts during the PATH walk; the Codex WSL
command builder and the WSL branch of isCommandOnPath did not. So Orca could
report the guest codex as installed and then launch the Windows one sitting
ahead of it on PATH, or disagree with itself between preflight and detection
about the same distro.

Both now pass the same option.

Verified on a real Windows host against a real WSL2 distro, with a Windows
binary planted ahead of a guest one on PATH:

  plain `command -v orcaprobe` -> /mnt/c/Users/neil/orca-agree/orcaprobe
  this lookup                  -> /home/neil/.orca-agree/bin/orcaprobe

That host reports /mnt/c as 9p, which the mount expression matches, so the
fstype list is confirmed against hardware rather than fixtures.

* test(preflight): prove the memoised mount list applies past the first command

Counting awk forks with a stub that reports no mounts cannot see what the
hoist trades correctness for. A mutant that empties `_orca_win_mounts` inside
the walk keeps the fork count at 1 and keeps every existing test green, while
every agent after the first stops skipping /mnt.

This runs two commands behind a stubbed Windows mount and asserts both resolve
to the guest binary. Verified against that exact mutant.

Credit: review counsel.
2026-08-23 02:58:40 -07:00
NeilandMelih b2902cb61e fix(agent-resume): restore Kimi Code sessions after restart (#15883)
Co-authored-by: Melih <mberatsanli@gmail.com>
2026-08-23 02:26:52 -07:00
Neil 92315c4178 fix(preflight): do not count a Windows binary reached through interop as a WSL install (#16028)
* fix(preflight): do not count a Windows binary reached through interop as a WSL install

WSL appends the Windows PATH to the guest PATH by default, so on a distro with
no guest `claude`, `command -v claude` resolves to
`/mnt/c/Users/me/.../claude.exe`. That path is POSIX-absolute, so the existing
absolute-path check accepted it and preflight reported the agent as installed
in the distro.

That is worse than reporting it absent. Absent tells the user to install it; a
false positive launches a Windows executable inside a Linux session, where it
sees Windows paths, no guest $HOME and none of the distro's config -- and the
failure surfaces later, somewhere less obvious.

Rejects `/mnt/<drive>/` and any `.exe`, case-insensitively. A genuine guest
install is unaffected.

* fix(preflight): skip Windows mounts during the PATH walk, not after it

The review caught this and it is the more important half of the fix.

Rejecting the interop path in TypeScript happens after the guest walk has
already stopped on it: the lookup breaks at the first executable, and the
version-manager fallback dirs are APPENDED, so they sit behind the Windows
entries WSL appends. A user with claude in nvm AND on the Windows PATH
therefore went from a false positive to "not installed" -- the exact #9725
population the fallback dirs exist to serve. Worse than the bug being fixed.

The lookup now takes `skipWindowsMountDirs` and skips those PATH components
mid-walk, so the guest binary behind the shadow is still found. Matched by
mount metadata from /proc/mounts (drvfs/9p/virtiofs), not by a `/mnt` name:
the automount root is configurable, and `/mnt` is an ordinary directory on a
Linux box. That also closes the custom-root hole the reviewers found in the
name-based predicate.

The TypeScript check stays as a secondary net for a mount the guest does not
report, with a comment saying why it must never be the thing that decides.

Proven with a real /bin/sh: a Windows `claude` ahead of an nvm `claude` on
PATH now resolves to the nvm one.

Credit: review counsel, and community PR #12794 (spfcraze), which proposed
this shape first.

* fix(preflight): let the mount table be the only word on what is a Windows path

The name-based check could veto a path the walk had deliberately kept. /mnt/d
is a perfectly ordinary Linux mount, so a guest binary there was resolved
correctly by the walk and then discarded by its name -- the #9725 false
negative, reintroduced by the belt-and-braces net I added "just in case". And
if awk were missing, the name rule became the only rule, which is precisely
the failure it was supposed to backstop.

The walk skips components the guest itself reports as drvfs/9p/virtiofs. That
is authoritative. Without a mount table we now degrade to main's behaviour (the
old false positive) rather than inventing a new false negative.

Net: one predicate, three fixtures and an import deleted.
2026-08-23 00:57:05 -07:00
Neilandhwantage 202d74a8a4 fix(git): enable Windows long paths for worktree creation (local, sparse, and SSH hosts) (#15866)
Co-authored-by: hwantage <hwantagexsw2@gmail.com>
2026-08-22 23:00:31 -07:00
Neil 838f5bfb75 fix(secrets): tell Linux users when their secrets are only obfuscated (#16033)
On Linux with no keyring, Electron falls back to the `basic_text` backend, which
"encrypts" with a hardcoded password. `isEncryptionAvailable()` returns true for
it, so Orca reported those secrets as sealed. They are not.

The obvious fix — returning false for basic_text — is wrong and would have been a
credential regression: `decryptWithStatus()` skips decryption entirely when
encryption is unavailable, so every already-stored secret would read back empty.
Sealing genuinely works on basic_text and must keep working.

So capability and trust are now separate questions. `isEncryptionAvailable()`
still answers "can this host seal and unseal", and `describeProtectionGap()`
(renamed from `describeUnavailable`) answers "is my data actually protected",
covering both no-sealing and weak-sealing.

That method had no production caller — the port documented a promise nothing
kept. `reportSecretProtectionGap()` now reads it at startup. A user-visible
surface is follow-up; this at least stops the silence.

Adds a bootstrap wiring guard over all nine host port installs. The no-op
defaults are correct for a renderer-less host and silently wrong for the desktop,
and a dropped or reordered install fails no existing test. Verified in both
directions: it fails when an install is removed, and when one moves after the
runtime is constructed.
2026-08-22 22:30:11 -07:00
Neil f975035809 refactor(ipc): split preflight and SSH registry out of the ipcMain modules (#15927)
* refactor(preflight): split agent detection out of the ipcMain registration

First of the IPC extractions the revised design requires. `src/main/ipc/preflight.ts`
mixed 285 lines of agent/tool detection with 35 lines of `ipcMain.handle`
registration, and the runtime calls that detection during normal operation
(`orca-runtime.ts:573`, plus the preflight RPC methods). So the runtime dragged
`ipcMain` into its graph to reach pure logic.

Detection moves to `src/main/preflight/agent-detection.ts` — named for what it
contains, per AGENTS.md. `ipc/preflight.ts` keeps only the handler registration and
re-exports the domain module so existing importers are unaffected. The runtime and
its RPC methods now import the domain module directly.

Ratchet baseline 36 → 35: `src/main/ipc/preflight.ts` is no longer reachable from
the runtime. The gate detected the improvement and refused to pass until the
baseline tightened, which is the behaviour it was built for.

Verified: 2 files / 1,187 tests pass across every suite touching preflight;
`pnpm typecheck` clean; `oxlint` clean.

* refactor(ssh): split the SSH target registry out of the ipcMain module

Second IPC extraction, and by far the biggest win: this removes **eight** modules
from the runtime's Electron graph, taking the ratchet baseline 35 → 27.

The runtime needed five thin accessors from `src/main/ipc/ssh.ts` —
`connectRegisteredSshTarget`, `getRegisteredSshState`, `listRegisteredSshTargets`,
`listRegisteredRemovedSshTargetLabels`, `getActiveMultiplexer`. Each is a one-line
read over module-level state. Importing them dragged in `ipcMain`, `powerMonitor`
and a `BrowserWindow` accessor — and, transitively, `ipc/pty.ts` (8,031 lines),
`ssh-browse`, `ssh-passphrase`, `ssh-relay-deploy`, `ssh-remote-cli-host-passthrough`,
`wsl-hook-relay-launch` and `user-data-path`.

`src/main/ssh/ssh-target-registry.ts` now holds that state plus its accessors.
`registerSshHandlers` populates it; the runtime reads it. The indirection is kept
deliberately: SSH providers register after construction and may reconnect, so
callers must resolve the current generation rather than freeze one.
`ipc/ssh.ts` re-exports all five, so non-test importers are unaffected.

`connectRegisteredSshTarget` still throws `ssh_handlers_not_registered` when no
handler layer registered — a headless host must fail loudly rather than report a
target as unreachable, which would read as `exited` (see ssh-execution-boundary.md).

Verified: 9 files / 59 tests across the ssh, automations and trust-preset suites;
orca-runtime.test.ts 1,183 pass; `pnpm typecheck` clean; `oxlint` clean.

* refactor(host): resolve the app root through the port in fork-reachable modules

`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.

`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.

Ratchet baseline 27 → 25.

Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.

* test(ssh): mock the SSH target registry alongside the ipc/ssh mock

Thirty-eight suites mocked `vi.mock('./ssh')` for `getActiveMultiplexer`. That
factory went inert when production started importing the accessor from
`../ssh/ssh-target-registry`, so the real module loaded and the assertions drifted.

Adds a companion registry mock returning the same stub, plus a
`sshTargetRegistryModuleMock` builder beside the existing `sshModuleMock` so the
shared harness stays one place. No assertion changed.

Found by a full-suite run: the targeted ssh/runtime suites were green while
30 tests in ipc/worktrees and ipc/repos were not.

* refactor(runtime): read app paths and the packaged flag through the port

`orca-runtime.ts` is the last module in its own graph that imports `electron`
directly. Nineteen of its uses were `app.getPath` (12) and `app.isPackaged` (7) —
exactly what the AppEnvironment port already covers.

Also removes a dead `const { app } = require('electron')` inside
`getOrchestrationDb`. It was left unused once the path came from the port, and it
is precisely the dynamic-require pattern `plain-node-entry-guard.ts` exists to
catch, sitting in the runtime's own constructor path.

What still binds `orca-runtime.ts` to Electron is now three sites, not nineteen:
`new Notification(...)` (one), `BrowserWindow.fromId` (one), and the
`ipcMain.on('terminal:tabCreateReply')` renderer round-trip — which is the browser
tab path, and the same one that would hang a headless host for ten seconds.

Two suites drove `electronMocks.app.isPackaged` directly; they now install a fake
AppEnvironment reading the same mutable field, so their per-test toggles work
unchanged and no assertion moved.

Verified: 376 files / 4,717 tests across src/main/runtime; typecheck and oxlint clean.

* test(serve): add the built-artifact terminal round-trip acceptance smoke

"The server started" proves almost nothing. Terminal creation dispatches into
OrcaRuntimeService, and without an installed headless PTY controller that path
falls through to a renderer reply that never arrives and times out after ten
seconds. A boot probe, a port bind, and a `host.platform` call all pass against a
server whose terminals are dead — which is exactly the gap the design doc's own
boot proof was retracted for.

This boots the BUILT `out/main/index.js --serve`, parses its ready payload, pairs a
real client over the advertised endpoint, lists worktrees, creates a terminal, runs
a command through the PTY, asserts the output comes back, and asserts clean
shutdown. It drives nothing but the public pairing + RPC surface, so the same
script is the acceptance gate a future Node-only backend must pass unchanged.

The sentinel invokes `process.execPath` rather than `echo`, because the shell
differs per platform and node does not.

Verified both directions: passes against the real server, and fails with an
actionable message when the command produces no output — a smoke that cannot fail
is worthless.

* fix(ssh): fail loudly when the multiplexer resolver was never installed

`getActiveMultiplexer` resolves through a resolver that `ipc/ssh.ts` installs at
module scope. A process that never loads the SSH layer — which is the whole point
of the Node-only backend — would get `undefined` from every call.

`undefined` already means something specific here: "not connected". So a missing
resolver and a disconnected target were indistinguishable, and a host with no SSH
layer would quietly report every target as not connected. That is the
unverifiable-reported-as-exited conflation `docs/reference/ssh-execution-boundary.md`
exists to prevent — the doc is explicit that absence of contact is never evidence
of absence of the thing.

A missing resolver is a wiring error, not a connection state, so it throws, matching
what `connectRegisteredSshTarget` already does for unregistered handlers.

Verified: 432 files / 4,759 tests across ipc, ssh, preflight, automations and trust
presets; typecheck and oxlint clean.

* refactor(pty): stop faking a BrowserWindow for the headless PTY path

`registerHeadlessPtyRuntime` passed `registerPtyHandlers` a stub object cast to
`BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send`
was a no-op — a window-shaped thing that lied about being a window, purely to
satisfy the type. Adversarial review named it as the same "looks fine, silently
returns a lie" pattern this codebase rejects elsewhere, and it is the shape that
keeps `electron` on a path that otherwise needs none.

`registerPtyHandlers` now takes `BrowserWindow | null`. An absent renderer is
semantically identical to a destroyed one — all 42 call sites already guarded on
`isDestroyed()` and skipped — so `src/main/ipc/pty-renderer-surface.ts` states that
directly: `isRendererGone`, `sendToRenderer`, `rendererWebContents`. The compound
`isDestroyed() || webContents.isDestroyed()` guards collapse into one predicate.

`isPtyWriteEventFromMainWindow` becomes null-tolerant and fails closed: with no
renderer no sender can legitimately match, so every write is rejected. Those
handlers cannot fire headless today, but failing closed is the right answer if that
ever changes.

This is the precondition for installing a PTY controller without Electron, which is
what a Node-only backend needs and what `terminal.create` actually calls.

Verified: 129 files / 2,473 tests across ipc/pty, providers and orca-runtime; the
built-artifact acceptance smoke still passes end-to-end (boot → pair →
terminal.create → sentinel → close), which is the check that matters most here
since this changes the headless PTY path itself; typecheck and oxlint clean.

* refactor(pty): read app paths and the packaged flag through the port

Follows the fake-window removal. `ipc/pty.ts` had nine `app.*` reads — all
`getPath`, `getVersion` or `isPackaged` — which the AppEnvironment port already
covers. The `BrowserWindow` import was also dead after the null-window change.

What still binds this file to Electron is now `ipcMain` (75 uses, all handler
registration) and `powerMonitor` (2). That is a clean statement of the remaining
job: split logic from registration, the same shape already applied to preflight
and the SSH registry.

Test wiring: the shared `pty-ipc-suite-environment` beforeEach installs a fake
AppEnvironment that reads through the existing `vi.mock('electron')` app object
rather than freezing values — suites toggle `app.isPackaged` mid-test to exercise
dev-mode spawn paths, so the port has to observe the same mutable field. One edit
in the shared harness covers every pty suite.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes; typecheck and oxlint clean; ratchet unchanged at 25.

* refactor(pty): inject the ipcMain surface so the PTY module loads without Electron

This closes the round-3 blocker: "the doc never says how orcad installs
setPtyController without Electron."

`registerPtyHandlers` owns the `RuntimePtyController` that `terminal.create`
actually spawns through — the thing a Node backend needs and cannot get from the
provider thunks. The module was otherwise host-agnostic already; the only thing
pinning 8,031 lines to Electron was a static `ipcMain` / `powerMonitor` import used
purely to register renderer handlers that no headless host will ever receive.

`src/main/ipc/pty-host-bindings.ts` makes those surfaces settable, defaulting to
no-ops. Unlike AppEnvironment and SecretStore, the default does NOT throw: a host
with no renderer legitimately has nothing to register against, so not registering
handlers nobody can call is correct rather than a hidden downgrade. The desktop
installs the real objects in `attach-main-window-services` before its handlers run.

Also converts the remaining electron import to a top-level `import type`. oxlint's
`no-import-type-side-effects` caught that inline `type` specifiers still leave a
side-effect import — precisely the "type-only is not enough if esbuild still emits
require('electron')" trap a reviewer flagged.

**`src/main/ipc/pty.ts` now bundles with zero `require("electron")`.** A Node entry
can call `registerPtyHandlers(null, runtime, …)` and get a working PTY controller.

Verified: 128 files / 1,290 tests across ipc/pty and providers; the built-artifact
acceptance smoke passes end-to-end — which is the check that matters, since this
changes how every PTY handler registers; typecheck and oxlint clean.

* fix(pty-bindings): drop two unused eslint-disable directives

CI runs oxlint with unused-disable reporting; the two
`@typescript-eslint/no-explicit-any` suppressions I added were never triggered by
any enabled rule, so they failed static analysis as dead directives. The `any[]`
rest args stay — they mirror electron's own IpcMain signature, and narrowing them
would reject the real object at the desktop call site.

Verified with the exact CI invocation: `oxlint --format github` reports 0 warnings,
0 errors across the repo.

* fix(pty): install the host bindings per process, not per window

A real regression my own change introduced, caught by the SSH docker E2E
(`paired-startup-exec-readiness` — "recovers startup exec through a headed paired
desktop owner"). It reproduced on rerun, so it was not a flake.

`setPtyHostBindings` was called inside `attachMainWindowServices`, i.e. when a
window attaches. But `registerHeadlessPtyRuntime` (index.ts:3163) calls
`registerPtyHandlers` on the serve path *before* any window exists — so those
handlers registered against the no-op default and never reached the real `ipcMain`.
A paired desktop owner then attached to a runtime whose PTY handlers were wired to
nothing.

The bindings describe the *host*, not the *window*: an Electron main process always
has `ipcMain`, whether or not a window is open. Installing them beside
`setAppEnvironment`/`setSecretStore` at the top of bootstrap fixes both paths.

Verified: 128 files / 1,290 tests; the built-artifact acceptance smoke passes;
typecheck clean; `oxlint --format github` (the exact CI invocation) reports 0/0.

* feat(orcad): de-electron the runtime core and add the Node entry + build gate

**`src/main/runtime/orca-runtime.ts` — 41,048 lines — no longer imports electron.**
Its last three sites go through `runtime-desktop-surface.ts`: a native notification,
the authoritative-window lookup, and the one `ipcMain` channel used by the
renderer-backed tab-create fallback. All three are unreachable without a renderer —
`createTerminal` already takes the background branch when no window exists (#10333) —
so a Node host installs none and the runtime relays notifications to paired clients,
which is the better destination anyway. Ratchet 25 → 24.

Adds `src/main/orcad/orcad-entry.ts`: Node host adapters plus a `startOrcad` that
constructs the runtime, installs the PTY controller via `registerPtyHandlers(null, …)`,
and serves RPC. It sets two defaults the constructor gets wrong for a headless host —
`canRecoverPersistentLocalPtys: false` (no daemon here) and
`getDesktopWindowStatus: 'blocked'` (a Node host can never be promoted to a desktop
window, which is what `'openable'` claims).

Adds `config/scripts/build-orcad.mjs`, which **currently fails, on purpose**: 25
modules still import electron (browser and speech clusters, plugins, jira/proxy,
filesystem-watcher, and four `require('electron').app` one-liners). It names them.

Two bugs found while building it, both worth recording:
- The first bundle looked clean and was not. `electron` was bundleable, so esbuild
  rewrote the metafile `path` to the resolved file under node_modules and a check for
  `path === 'electron'` passed while the package was in the bundle — it failed at
  runtime with electron's own installer message. The check now reads `original`, and
  electron is marked external so a residual import fails loudly instead.
- `jsonc-parser`'s UMD build breaks the bundle at load; aliased to its ESM entry, the
  same fix `build-relay.mjs` already carries.

Verified: desktop unchanged — the built-artifact acceptance smoke passes, runtime/pty/
provider suites green, typecheck clean, `oxlint --format github` 0/0.

* refactor(host): drop the last two require('electron') app lookups

`computer/sidecar-client.ts` and `ports/port-scan-command-client.ts` read the app
root through `require('electron').app` inside a try/catch. Both were already correct
under plain Node at runtime — they return null when it throws — but the literal text
fails the plain-Node entry guard regardless, which is why port-scan carried a comment
warning it must never become reachable from a fork entry.

Reading the AppEnvironment port gives the identical "no app root here" answer without
the text, so that warning is now obsolete and the comment says so.

Ratchet 24 → 22. Every remaining entry is a real coupling: the browser cluster (15,
which variant B does not ship), speech (2), plugins (2), and jira/proxy-settings (2,
needing an HttpClient port for Chromium session partitions).

Verified: 25 files / 209 tests; acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* docs(orcad): record that the ratchet under-counts orcad's graph

The ratchet reports 22 electron importers; the orcad build reports 23. The extra is
agent-hooks/wsl-hook-relay-launch.ts, and the cause is a gap in the gate rather than
a rounding error: the ratchet measures what orca-runtime + runtime-rpc reach, while
orcad's entry also imports ipc/pty directly to install the PTY controller.

Once orcad ships it must become a ratchet entry point, or the two numbers drift and
the gate quietly stops covering the artifact it exists for.

* refactor(runtime): inject the browser commands factory

Drops 14 modules from the runtime's Electron graph in one change — the whole Chromium
browser cluster. Ratchet 22 → 8.

`OrcaRuntimeService` constructed `RuntimeBrowserCommands` as a field initializer, and
that construction is what pulled in `BrowserWindow`, `session`, `webContents` and the
cookie jars. Importing the class for its *type* is free; only building it costs.

So the class import becomes `import type`, and the instance comes from
`runtime-browser-commands-factory.ts`. The desktop installs the real factory at the
Electron entry. **All ~80 existing `this.browserCommands.*.bind(...)` delegations are
untouched** — a review round specifically warned that rewriting those was the
expensive, risky part, and this avoids it entirely.

With no factory installed, browser commands reject per call with `browser_unavailable`
rather than resolving to a stub that silently succeeds. The runtime already filters
browser capabilities out of `getStatus()` when no backend exists, so clients do not
offer the affordance in the first place.

Also corrects a stale comment in `pty-renderer-surface.ts` that still described the
fake window as present tense; it was deleted two commits ago.

Verified: 451 files / 5,513 tests across `src/main/browser` and `src/main/runtime` —
the entire browser automation suite; the built-artifact acceptance smoke passes;
`pnpm typecheck` and `oxlint --format github` clean.

* refactor(host): extract the plugin client list and port two app lookups

Ratchet 8 → 5.

- `listPluginsForClients` moves to `src/main/plugins/plugin-client-list.ts`. It needed
  only three `plugins/*` helpers, none of them Electron — it was colocated with
  `ipcMain.handle` registrations, so the runtime's `plugins.list` RPC dragged all of
  Electron in to call a function that reads a lockfile. Same shape as preflight.
  Dropping it also releases `ipc/plugin-marketplaces.ts`.
- `agent-hooks/wsl-hook-relay-launch.ts` and `speech/stt-service.ts` read `getAppPath`
  and `isPackaged` through the AppEnvironment port.

The five that remain are all genuinely Chromium and need the HttpClient port or a
watcher split, not another mechanical swap: `browser/cdp-bridge` (webContents),
`ipc/filesystem-watcher` (ipcMain), `jira/authenticated-request` and
`network/proxy-settings` (net + session partitions), `speech/model-manager`
(`net.request`, which honors app proxy settings that Node https does not — replacing
it is a behaviour change, not a rename).

Verified: 219 files / 1,922 tests across plugins, speech, agent-hooks and the runtime
RPC methods; the built-artifact acceptance smoke passes; typecheck and
`oxlint --format github` clean.

* refactor(network): resolve the default proxy session lazily

Ratchet 5 → 4.

`proxy-settings.ts` needed exactly one Electron value: `session.defaultSession`, as
the fallback when a caller does not pass `options.proxySession`. Callers could already
inject a session; only the default was hard-wired. It now comes from a settable
resolver, so the module loads under plain Node.

**A resolver rather than a Session, because a Session eagerly throws.** The first
attempt installed `session.defaultSession` directly in pre-ready bootstrap and broke
startup outright — `TypeError: Session can only be received when app is ready`. The
acceptance smoke caught it before commit. Deferring to first use is always after ready.

Behaviour with no session is not a degradation: there is no Chromium proxy config to
discover, so `resolveProxy` is skipped and the environment variables become the whole
answer rather than a fallback. Applying rules to a session that does not exist is
likewise skipped; settings are still honoured because outbound requests read the env.

This reaches past Jira — a review round noted `ensureElectronProxyFromEnvironment` is
also on the Claude HTTP path via `oauth-refresh.ts` and `rate-limits/claude-fetcher.ts`.

Verified: 48 files / 526 tests across network, jira and rate-limits; the
built-artifact acceptance smoke passes; typecheck and `oxlint --format github` clean.

* fix(index): merge the duplicate proxy-settings import

CI's code-quality lint (`oxlint --config config/oxlint-code-quality-native-plugins.json
--deny-warnings`) flags a module imported twice in one file. My earlier insertion added
a second `./network/proxy-settings` import beside the existing one.

Verified with CI's exact invocation: exit 0.

* refactor(network): add the HttpClient port and lift BrowserError out of cdp-bridge

Ratchet 4 → 2.

Two unrelated couplings, both of the same shape — a small thing living inside a
Chromium-heavy file.

`BrowserError` is a seven-line error class with no dependencies, but it lived in
`browser/cdp-bridge.ts`, which imports `webContents`. The runtime catches that type on
paths with nothing to do with CDP, so one import kept a Node host from loading the
runtime at all. Moved to `browser/browser-error.ts`; cdp-bridge re-exports it.

`jira/authenticated-request.ts` fetches through `net.fetch` and reads
`session.defaultSession`. `network/http-client.ts` makes both settable. This one is a
**named port rather than a silent fallback, because the fallback is not transparent**:
Electron's net follows Chromium session/proxy state, avoids undici's stale keep-alive
sockets after a VPN path change, and sends a Chrome user agent that Jira's XSRF check
depends on. A Node host gets `globalThis.fetch`, reads proxy config from the
environment, and sends Node's user agent. That difference is documented at the port.

`session.defaultSession` is read per call, not captured at install — it throws before
the app is ready, which is the mistake the previous commit made and the acceptance
smoke caught.

Test wiring: `jira/client.test.ts` installs the port *inside* `loadClientModule`, after
its `vi.resetModules()`, since the reset gives the module a fresh singleton.

Verified: 461 files / 5,616 tests across jira, browser, network and runtime; the
built-artifact acceptance smoke passes; typecheck, `oxlint --format github` and the
code-quality lint with `--deny-warnings` all clean.

* fix(http-client): register the Node fetch fallback with the call-site audit

`global-fetch-call-site-audit.test.ts` guards every global-fetch use, because the
global runs on undici where an unread response body can crash the whole process
(orca#8695). The HttpClient port's Node fallback is a new such call site and was
unregistered — the guard caught it in a full-suite run.

Registered with the reasoning, and the port's doc comment now states the body-safety
contract explicitly: it hands the Response straight to its caller and never inspects
it, so the consume/cancel obligation stays exactly where it already was — with the
caller, unchanged from when they called Electron's net directly.

Two comments elsewhere mentioned the global by name and tripped the line scan as false
positives; reworded to describe the behaviour rather than name the API.

Verified: audit passes; typecheck and `oxlint --format github` clean.

* fix(app-environment): read hasAppEnvironment through the realm slot
2026-08-22 21:34:39 -07:00
NeilandMelih 7ce11dcf55 fix(agent-resume): restore Copilot sessions after restart (#15879)
Co-authored-by: Melih <mberatsanli@gmail.com>
2026-08-22 21:24:44 -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 d07ce15cff refactor(host): route secret storage through a SecretStore port (#15916) 2026-08-22 16:38:00 -07:00
Jinjing 02bee48e1d Retry transient ripgrep spawn failures instead of demanding install (#15983)
* retry transient ripgrep spawn failures instead of missing binary errors

Fork/exec pressure (EAGAIN, EMFILE, ENFILE, ENOMEM, ETXTBSY) should not
trigger ripgrep-not-found guidance. Add bounded retries (max 2x) for transient
spawn failures in Quick Open and file listing, respecting cancellation signals.
Introduce RipgrepLaunchFailureError to distinguish fork/exec pressure from
unavailable ripgrep installations.

* Handle cancellation during transient spawn failure retry window

When a query is cancelled after a transient ripgrep spawn failure but before
the retry decision resumes, the cancellation must be reported to the caller
rather than proceeding with a retry attempt.
2026-08-22 13:06:38 -07:00
NeilandJinjing 6e18c18e58 fix(automations): localize schedule weekday names and labels (#15884)
* fix(automations): localize schedule weekday names and labels

The Weekly Day picker rendered a hardcoded English tuple, and shared
schedule labels built copy as `${day}s at ${time}` from an OS-locale
Intl weekday, so a non-English UI showed Sunday…Saturday (or 星期五s).

Shared now emits deterministic English (the CLI contract) plus a
locale-free AutomationScheduleDescriptor; the renderer formats that
descriptor through translate() with Intl/CLDR weekday names resolved
from getIntlLocale(). Fixes #14404.

* test(automations): assert localized weekday copy in rendered DOM

The existing coverage walked the React element tree, so nothing proved the
Day dropdown and cron status row reach the DOM localized. Mount the picker
under happy-dom with the Radix Select swapped for a native <select> (the
pattern RepositoryWorktreeDefaultsSection.test.tsx already uses, since Radix
portals its content only once opened) and read real option text.

Also key the weekday SelectItems by index rather than by translated copy, so
a runtime language switch reconciles instead of remounting all seven items.

* fix(automations): keep the weekday SelectItem key off the array index

react-doctor(no-array-index-as-key) rejects `key={index}`; the localized
weekday name is already unique per locale, so keep it as the key.

* fix(automations): match the real AutomationDraft shape in the render test

The fixture invented `repoId`/`branchMode`/`enabled` fields; runtime ignored
them but `tsc` did not. Mirror AutomationSchedulePicker.test.ts's fixture.

* fix(automations): localize the custom-cron field chips

The five cron field headers rendered one row above the status row this PR
localizes were still hardcoded English, so a Chinese UI showed
Minute/Hour/Day/Month/Weekday. Same defect shape as the deleted DAY_OPTIONS
array. Chip keys move to stable field ids so a locale that renders two fields
with the same word cannot collide, and the truncated header carries a title so
longer copy (es 'Dia de la semana') stays readable.

* fix(automations): keep weekday option keys stable

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-08-22 10:53:55 -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
NeilandOrcaWin 98c03fe12f fix(win32): hide the console window for agent-browser and git helpers (#15887)
* fix(win32): hide the console window for agent-browser and git helpers

W1 routed most child processes through `runProcess`, which always sets
`windowsHide`. Six call sites still spawn directly, so each one opens a real
console window on Windows: it flashes and steals foreground. For the git status
poll, that is once per poll (#10488).

A ratchet now scans every file that imports `child_process` and fails on a call
without the flag. Its allowlist starts at the 76 files that still offend and can
only shrink — it doubles as the worklist for routing them through the chokepoint,
which is where the flag stops being a per-call-site decision at all.

Diagnosed in #14589; the SSH and cookie-import sites it also covered are already
fixed on main by the W1 migration.

Co-authored-by: OrcaWin <orcawin@users.noreply.github.com>

* test(wsl): stop the exec-mode guard scanning historical release checkouts

The cross-version e2e lane checks whole past releases out under
`tests/e2e/.cross-version-checkouts/`. The guard walked into them, so on any
machine that had run that lane it reported 21 offenders -- every one a copy of
shipped code we cannot edit -- and failed. Skip dot-directories; the >500-file
vacuity assertion still holds.

---------

Co-authored-by: OrcaWin <orcawin@users.noreply.github.com>
2026-08-22 00:18:02 -07:00
Jinwoo Hong 1354ff534f fix(cmd-j): host-qualify browser and simulator tab candidates (STA-4965) (#15686) 2026-08-21 23:17:54 -07:00