Commit Graph
10035 Commits
Author SHA1 Message Date
Neil 95eed52801 fix(cli): report which hosts a worktree listing covered, and stop the cap starving remote ones (#18417)
`orca worktree list` returned zero of 24 SSH worktrees at the default limit
(#18104). Rows are resolved repo by repo, so every SSH repo's rows land
contiguously at the end of the fleet order — the 24 remote rows sat at indices
496-520 of 521 and a plain `slice(0, 200)` never reached them.

The omission was not fully silent: text output printed `truncated: showing 200
of 521` and JSON carried `totalCount` / `truncated`. What was missing is that
the omission was *categorically every remote host* — no host column, no
`hostScope`, nothing to distinguish "200 of 521" from "one host is entirely
absent". Per docs/reference/ssh-execution-boundary.md, a listing that does not
name its scope reads as absolute.

Adopt the mechanism `terminal list` already has rather than inventing a second
one:

- `RuntimeTerminalListHostScope` becomes an alias of a shared
  `RuntimeListingHostScope`, now also carried (optional, so old hosts are
  unaffected) on `worktree.list` and `worktree.ps` results.
- `src/shared/host-balanced-listing-page.ts` round-robins the row cap across
  hosts and returns the survivors in the caller's original relative order, so
  the page stays a subsequence of the unbounded listing and nothing downstream
  re-sorts. An uncapped listing is returned unchanged.
- `worktree list` / `worktree ps` text output gains a `host=` column and the
  same trailing `scope:` line `terminal list` prints.

Third defect, same mechanism: `hostScope.omittedHostIds` is built from the
runtime's own bookkeeping, so it names `runtime:` ids for servers that are no
longer paired — 6 of 9 in the recorded QA run hard-error when queried. Since
`hostScope` is *the* documented way to complete a partial listing, that makes
the mechanism unreliable for its intended use.

Annotate rather than filter. Dropping an id would shrink what the listing
admits it did not cover, and the boundary doc requires a listing to name its
gaps — the gap is real whether or not this machine can name the host that owns
it. `src/cli/omitted-host-scope-selectors.ts` resolves each omitted id against
this machine's pairing store and the runtime's SSH-target registry and attaches
the exact flag that reaches it, or `null` marked "not selectable from this
machine". This is a client-side annotation: nothing new goes over the wire, it
answers "can I select it" and never "is it up", and the SSH round trip is only
paid when an `ssh:` host was actually omitted.

No `--host` filter was added; the host column plus scope line covers the
reported need without a new selector axis.
2026-09-03 14:43:09 -07:00
Neil 9bed758e36 fix(cli): reject runtime selectors on host list and environment list (#18405)
`orca host list --environment m4air` was not ignoring the flag — it was applying
it to half the answer. `shouldIgnoreRemoteSelection` never pinned the `host`
family, so the SSH-target lookup was routed to m4air while paired servers were
still read from this machine's own pairing store, and the handler stamped the
envelope `_meta.runtimeId: "local"` regardless. The result was one listing
describing two hosts: the openclaw row silently disappeared, which reads as
"m4air has no SSH targets". `environment list --environment X` had the pin but
no guard, so the flag vanished with no signal at all.

Reject rather than route. `host list` answers "what can this machine target and
with what flag"; its paired-server half comes from a client-local store and
cannot be routed at all, so any routed answer is necessarily half-substituted —
rule 1 of docs/reference/ssh-execution-boundary.md. `environment list` is
entirely client-local, so there is no other host to ask. This matches the
`account` and `artifacts` precedent, the only two pinned families that already
paired the pin with a rejection guard.

- pin the `host` family so an ambient ORCA_ENVIRONMENT cannot produce the same
  two-machine listing with no flag to reject; `runtimeId: "local"` is now true
- extract the duplicated `rejectRemoteSelectionFlags` from account.ts and
  artifacts.ts into src/cli/remote-selection-flag-rejection.ts
- `environment show` / `environment rm` / `environment add` are untouched: there
  `--environment` and `--pairing-code` name the row to act on, not a route
2026-09-03 14:43:05 -07:00
Neil 232d04f541 fix(dashboard): open remote sessions from every agent reveal path (#18403)
Three reveal paths called bare setActiveWorktree + activateTabAndFocusPane,
skipping setActiveView('terminal'), ensureWorktreeHasInitialTerminal and
resumeSleepingAgentSessionsForWorktree. A parked SSH workspace has no resident
tab until those run, so the reveal landed on a workspace with no terminal.

Route all three through the incumbent activateAndRevealWorkspace dispatcher
(which the sidebar and "Jump to workspace" already use, and which also handles
folder workspaces). The Activity row-click additionally early-returned when the
thread's tab was absent from tabsByWorktree/unifiedTabsByWorktree, which made a
cold-parked remote thread a silent no-op; residency is now probed after
activation, so a revived tab is focused and a genuinely retained thread still
activates its workspace instead of doing nothing.

Also stop asserting `exited` from an absence of local state: SshPtyProvider
reports no authoritative buffer snapshot and the relay has no snapshot RPC, so
a null preview snapshot for a remote pty is loss of contact. The preview and
the no-pty dialog branch now say the remote preview is unavailable rather than
claiming the pane closed. Adding the relay snapshot RPC stays out of scope --
it needs capability negotiation.

Fixes #16731
2026-09-03 14:43:01 -07:00
Neil 5a626dcdf4 refactor(git): share push-target resolution between local and the SSH relay (#18406)
`src/relay/git-handler-push-target.ts` and `src/main/git/remote.ts` carried
identical ~160-line copies of the resolver that decides which remote a plain
`git push` hits. Identical today is exactly when to share it: the cost of a
future divergence is pushing to the wrong remote, which retrying does not undo.

Move the resolver to src/shared/git-push-target-resolution.ts, parameterized on
a `(args) => Promise<{ stdout }>` runner — the only thing the two hosts actually
differ in — and delete both copies. The relay entry point keeps only the work
that is genuinely relay-side: re-validating an explicit target that arrived over
the wire and running `check-ref-format` on it.

No behavior change on either path, and nothing new or different is published, so
this engages no rule in remote-wire-compatibility. No git command changes.

src/relay/git-push-target-local-parity.test.ts scripts one repository's config
and requires `git.push` over the real relay dispatcher and the desktop's
`gitPush` to emit the same push argv, plus the argv each case should produce.
2026-09-03 14:42:58 -07:00
Neil 53adf5e2e6 fix(git): share one failed-command error-text reader between local and the SSH relay (#18398)
* fix(git): share one error-text reader between the local and relay branch-delete fallbacks

The relay and the desktop each carried their own `getErrorText`, and they had
drifted: the relay read `message` + `stderr` + `stdout`, the desktop only
`message` + `stderr`. A `git branch -d` refusal arriving on `stdout` therefore
routed the SSH removal through prune-and-retry while the local removal gave up
and preserved the branch.

Against a real binary the two agree, because Git prints the refusal through
`error()` on every supported version — verified on 2.25.1, 2.38.1, 2.49.1 and
2.55.0, none of which put a byte of it on stdout. What the desktop copy actually
missed is that Orca classifies errors it built itself, with the Git output on
`.stdout`: `worktree remove`'s submodule retry attaches `git status --porcelain`
that way on both paths. The stdout-reading form is also already the shared
spelling — `isSubmoduleWorktreeRemovalRefusal` uses it for both hosts — so this
converges on it rather than on the shorter one.

Move the reader to src/shared/git-command-failure-text.ts and the predicate it
feeds to src/shared/git-branch-delete-refusal.ts, and delete all three copies.
The predicate carries both refusal wordings live in the supported range: Git
through 2.40 says "checked out at", 2.43+ says "used by worktree at".

The real-binary contract now pins that boundary: the refusal is recognized, it
lands on stderr, and stdout stays empty on every Git in the matrix.

* fix(test): consolidate the duplicate worktree import in the parity test
2026-09-03 14:42:54 -07:00
Jinwoo Hong d66386bc82 fix(cloud): bound and yield the relay's global cell-inventory lock (#18521)
Mirrors stablyai/orca-cloud#471 (squash c3354e8), byte-identical under cloud/.

The relay's cell-inventory lock (SELECT ... FROM relay_cells FOR UPDATE over
all 23 rows) is one global critical section shared by the assignment hot path
and every director sweep; with the pool's 1s lock_timeout a blocked waiter held
a pooled client for a full second, producing ~690 55P03 retries per 5 minutes
in production. Request paths now bound the wait at 500ms with a SET LOCAL that
is restored to the pool default before the next statement; director-only sweeps
take the lock NOWAIT and skip the tick; sweep timers are jittered; hold time is
exported as additive runtime-metrics fields so the bound can be tuned.
2026-09-03 17:27:57 -04:00
Jinwoo Hong 4d24fb340b fix(mobile): stage-aware relay dial bound so a slow cell is not hung up on (#18518)
A phone returning to foreground on 2026-09-03 logged "replacement session
authentication timed out" five dials in a row while the desktop's relay
control was live. The cell (production-gce-c27) had taken relay-auth but
its assignment/reservation transactions were lock-contended (55P03 retries,
14–16s per accept); the phone's flat 12s migrateTo bound closed the socket
2–4s before the cell finished (cell logged host_data_reservation_already_bound),
and because the timeout counted as a director-class failure the phone
re-resolved the same cell and waited 12s again before logging — every
retry landed in the same contended window.

- MobileRelayE2eeLink reports onOpen once relay-auth is on the wire;
  MobileRelayRpcSession exposes a dial stage
  (opening → awaiting-hello → handshaking → confirming).
- waitForAuthenticated keeps the caller's bound until the socket opens, then
  re-arms a per-stage budget (30s awaiting-hello, 12s handshaking, 35s
  confirming) so a reachable, slow cell is not treated as a black hole.
- The timeout error carries the stalled stage and shows up in the
  "relay dial failed" log line; a stall past the open socket no longer
  triggers the director re-resolve round.

Phone-local only: no wire change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-03 17:16:35 -04:00
Jinwoo Hong aa78d4af17 fix(release): restore version and harden staging confirmation
Resolves release scan blockers STA-6611 and STA-6612.
2026-09-03 16:53:32 -04:00
Jinwoo Hong a35451f5b9 fix(relay): stop self-closing the control socket on unknown messages (#18400)
* fix(relay): stop self-closing the control socket on unknown messages

The desktop control client tore its own relay control WebSocket down with
code 4401 "unknown control message" for any well-formed control frame it
did not recognize. handleMessage() funneled everything that was not
ping / conn-open / drain / a tracked request reply into
failProtocol('unknown control message'), which closes the socket and
orphans the origin.

Three real frames hit that branch:

- A relay reply that arrives after the desktop's 10s request deadline
  already deleted the pending entry. Relay control operations run DB
  transactions that can exceed 10s under load, so resolveMessage() finds
  no waiter and returns false.
- A control-error carrying no reqId (or an unknown one), including the
  relay's own 'unknown_control_message' reply to a host command it could
  not route.
- A newer relay's opcode that this build predates.

Fleet telemetry shows ~15 of these closes per day across app versions
1.4.175..1.4.197, so it is version-agnostic. The self-close was also far
more costly than the message that caused it: the relay session dropped to
'orphaned' and answered the phone with HOST_OFFLINE (4404) for the orphan
grace window, then the desktop had to re-register through the director's
503 reconnect throttle, stretching a single stray frame into minutes of
mobile downtime.

Per docs/reference/remote-wire-compatibility.md Rule 2, an unknown but
well-formed control frame must be dropped, not treated as fatal. Log and
ignore it; malformed JSON, binary frames, and messages before activation
still close as protocol violations.

Adds unit tests for the unknown-opcode drop, the timed-out-reply drop,
and the preserved malformed-frame teardown.

* docs(relay): correct the ignore rationale, drop the Rule 2 misattribution

Rule 2 of remote-wire-compatibility governs the SENDER of a new terminal-
stream opcode and treats the receiver's silent drop as a hazard, not a
mandate. Reframe the comment around the actual justification: the decoder
convention of dropping unknown frames, the control channel's lack of an
opcode negotiation step, and the incident cost asymmetry.
2026-09-03 16:37:42 -04:00
Brennan BensonandMerge Sim 91c5a615c5 fix(settings): indent the Agent sleep "Sleep after" sub-setting (#18379)
"Sleep after" rendered flush with its parent toggle, unlike the
Agent Dashboard and Chat UI sub-settings which sit inside the
indented, left-bordered group. Reuse that same wrapper and drop the
row's extra vertical padding so the block matches its siblings.

Co-authored-by: Merge Sim <sim@local>
2026-09-03 13:22:42 -07:00
Jinwoo Hong 16e2624578 fix(terminal): flush xterm's parked renderer resize when releasing the pause latch (#18510) 2026-09-03 13:02:46 -07:00
Jinwoo Hong 0746d82c01 chore(cloud): close the Workload Identity cutover onto stablyai/orca (#18509)
Mirrors stablyai/orca-cloud#470. The private relay workflows are retired, so
the dual accept has one live arm left. Add `github_workflow_file_prefix` for
the primary repository's workflow filenames, point `github_repo`/
`github_repo_id` at `stablyai/orca` (`1183888342`), and empty
`github_accepted_repositories` in both environments. Every relay provider goes
back to a single arm naming `cloud-` prefixed workflow refs.

`cloud/infra/terraform` stays byte-identical to the private branch. The two
identity tests diverge here as they already did, so they take the same change
rather than the same bytes: both now render the trusted ref head from the
Terraform variable instead of this checkout's own workflow filenames, which is
what lets the length pin be the same 791 characters in either repository.
2026-09-03 15:57:00 -04:00
Jinwoo Hong fbea749d07 chore(cloud): pin staging relay c3 to the director's image (#18508)
* chore(cloud): pin staging relay c3 to the director's image

Mirrors stablyai/orca-cloud#468. c3 stayed on sha-c91439af after the
director and c4 moved to sha-e3e92d95, so the staging capacity proof's
compatible-director-image check has failed since 2026-08-14.

* test(cloud): scope the launch-image pin to staging C4 now that C3 shares the digest

* test(cloud): keep the public workflow assertions; scope only the launch-image pin to C4
2026-09-03 15:36:41 -04:00
github-actions[bot] 3cd817c250 Update README downloads badge 2026-09-03 18:31:46 +00:00
Jinjing 0f22e1e905 Fix table header transparency with opaque background (#18499)
Replace the translucent bg-muted/25 with an opaque color-mix blend
(40% muted on background) to ensure scrolled rows don't show through
the sticky header. Add test coverage for header styling and layout.
2026-09-03 11:16:40 -07:00
Jinwoo Hong 67e22345da fix(cloud): stop passing manage_artifact_dns to the relay root (#18442)
The relay root does not declare it (it belongs to the private apps root), and
Terraform rejects an undeclared -var, so the first public Deploy Relay Staging
run failed at the C4 image bind.
2026-09-03 07:18:29 -04:00
Jinwoo Hong 3de1b9d058 fix(cloud): stop asking setup-node to cache the pnpm store in the relay workflows (#18432)
setup-node's cache: pnpm runs 'pnpm store path' from the repository root,
where packageManager pins pnpm 12; the shim it downloads fails to execute on
the runner, so the step dies before auth. Cloud Verify never used the cache
and passes; the six relay workflows that copied it from orca-cloud (root
pnpm 10 there) now match.
2026-09-03 07:05:21 -04:00
Jinwoo Hong 3eec77c11a chore(cloud): add the relay fence broker, ops console, Terraform root, scripts, and 24 cloud-* workflows (#18413)
Phase 6 of the relay split: the relay's deploy/operate surface moves under cloud/ with 24 cloud-* workflows gated on ORCA_CLOUD_OPERATIONS_ENABLED, the Cloud SQL rollout lease action, the relay Terraform root (dual-accept identities for both repositories), scripts, docs, CODEOWNERS, and a terraform validate job in Cloud Verify.
2026-09-03 06:55:14 -04:00
Neil 4cc0b8de61 perf(hot-paths): delete allocation-only work in sort, explorer, monaco, rpc, snapshots (#18372)
* perf(hot-paths): delete allocation-only work in sort, explorer, monaco, rpc, snapshots

* fix(perf): revert snapshot revision fast-path — same revision can carry a new session

* perf(hot-paths): drop the unproven rpc buffer rewrite, dedupe the equality helpers

- Revert the unix-socket chunk-carry change. Its comment claimed it avoided
  O(n^2) rescans, but chunks is reset to [remainder] every data event, so the
  join plus the tail byteLength is two passes where the old code did one;
  benchmarks showed no win. It also moved consumed-frame bookkeeping out of the
  closure, so a synchronous throw from the handler would re-dispatch frames.
- project-host-compatibility: fold the two byte-identical array comparators
  into one generic arraysEqualByJson.
- smart-attention: drop the leftover byTab.size === 0 branch that returned the
  same value as the line after it.
2026-09-03 03:20:59 -07:00
Neil f70f580627 perf(renderer): park hibernation and panel-watchdog work behind a hidden window (#18373)
* perf(renderer): stop five timers from ticking behind a hidden window

* perf(renderer): park hibernation and panel-watchdog work behind a hidden window

Narrowed from five timers to two, and made both correct:

- Gate on getWindowParkVisible(), not raw document.visibilityState. macOS can
  wedge visibilityState at 'hidden' with no further visibilitychange, which
  would park these for the rest of the session.
- Add a real becoming-visible pass via subscribeWindowParkVisibility, so resume
  does not wait out the remaining interval. Unsubscribed on stop.

Dropped the other three gates:
- terminal-delivery-watchdog: it is the recovery lane for the byte-drop bug the
  stale-visibility latch exists for; parking it costs a frozen terminal.
- crash-diagnostics: the dashboard-popout surface is normally occluded, so it
  would sample once at startup and never again.
- use-contextual-tour: attempts only increments past the gate, so a hidden
  window turned a self-clearing 20-attempt interval into a permanent one.

Tests stub visibilityState and the stale latch; both watchdog cases are RED
against the previous raw-visibilityState implementation.
2026-09-03 02:47:48 -07:00
Neil 10b5ac8c90 perf(renderer): narrow vault and editor subscriptions off the every-write path (#18374)
* perf(renderer): narrow vault and editor subscriptions off the every-write path

* fix(perf): revert EditorPanel narrowing — downstream hooks need the full openFiles list

* perf(renderer): make the vault session-id cache resettable between tests

Every production writer replaces agentStatusByPaneKey, but test fixtures
commonly mutate it in place, which would keep serving the key cached for that
identity. Fold the WeakMap into the existing reset hook.
2026-09-03 02:41:03 -07:00
Neil 7b530f1eb5 fix(crash-reporting): record Orca-initiated tree kills so a killed renderer is decidable (#18367)
* fix(windows): refuse tree-kills of Orca's own Chromium pids and record the rest

G2 is 20 field reports that share only a symptom. It is at least four
fingerprints: ~15 Windows `reason=killed exitCode=1`, 3 POSIX SIGKILL under
memory pressure (G4-oom), 2 duplicate reports of one macOS V8 Proxy Resolver
SIGKILL, and 1 `0x80000003` install-dir ACL crash (G1; #17740 ships in
v1.4.196 only, not 1.4.195). Nothing here claims to fix all of them.

Two changes:

1. Behaviour. `classifyWindowsTreeKillTarget` returns `own` for any direct
   child of the main process — which our renderer, GPU and network-service
   utility all are — so PTY teardown could `taskkill /T /F` Orca's own UI
   (#10680). Both that classifier and `terminateWindowsProcessTree` now refuse
   any pid Electron is currently accounting for in `getAppMetrics()`.

2. Diagnosis. An Orca-issued kill and an external one are byte-identical in
   every field the crash report records today, so the cluster is undecidable.
   Every main-process force-kill choke point now records a durable
   `self_tree_kill` breadcrumb, and `process_gone` reports carry
   `selfInitiatedTreeKills` naming the pid and its offset from the death.
   A refused kill records `self_tree_kill_refused_own_chromium`, which is
   falsifiable: if it ever shows up in the field, we were the killer.

* fix(crash-reporting): coalesce self-kill breadcrumbs and scope the discriminator

Round-1 review remediation. Three blocking findings, all accepted.

1. Breadcrumb flood (accepted). recordSelfInitiatedTreeKill wrote an
   uncoalesced durable crumb from two routine teardown paths, and the
   reviewer reproduced 12 terminal closes x 3 process groups completely
   evicting the 30-slot ring — including this PR's own refusal crumb — plus a
   forced writeSync per killed group. It now uses the existing
   recordCoalescedDurableCrashBreadcrumb (5s window for pid-addressed
   taskkills, 60s for routine group/job teardown), so a burst costs one ring
   slot and one flush. The refusal crumb is coalesced per victim pid, so a
   retry loop cannot flood while a distinct pid always gets its own crumb.
   Regression test replays the reviewer's exact 12x3 reproduction and asserts
   the refusal crumb and a pre-existing gpu_process_crashed both survive.

2. Undifferentiated count (accepted). posix-process-group and win-pty-job are
   structurally incapable of reaching a Chromium process, and scope was absent
   from the persisted string. Scope is now in every entry
   (`<scope>/<site>/pid<N> +Nms`), and the count is split:
   selfInitiatedTreeKillCount now counts only pid-addressed taskkills — the
   kills that can land on a recycled pid that is now our renderer — with
   pty-scoped sweeps in selfInitiatedGroupKillCount. The list is renamed
   selfInitiatedKills because it carries both, and sorts pid-addressed kills
   first so truncation never drops the discriminating ones for teardown noise.
   The reviewer's repro (routine macOS terminal close + unrelated exit-133
   crash) now yields selfInitiatedTreeKillCount undefined.

3. Recording gaps and a false comment (accepted). New
   admitSelfInitiatedTreeKill gate: it refuses own-Chromium pids and records
   the rest, and all three main-process taskkill families now go through it —
   terminateWindowsProcessTree plus codex-accounts/service.ts and
   claude-accounts (which keep their own spawn lifetimes). The false "single
   taskkill choke point" comment is gone. The runProcess choke point the
   investigation asked for is instrumented via a
   setProcessTreeKillObserver seam in src/shared/child-process — shared code
   runs in the CLI and relay so it cannot import the main breadcrumb store —
   registered in main preflight. The codex app-server POSIX group teardowns
   and the claude POSIX branch record too. The module doc no longer claims
   absence is discriminating: it enumerates what is instrumented and names the
   direct process.kill(-pid) sites that are not.

Non-blocking, also fixed:
- Breadcrumb calls moved out of the try blocks whose catch is the ESRCH
  contract (posix-pty-process-groups, codex teardown, claude POSIX), so a
  throw from the diagnostic path can never be reported as a failed kill.
- Detail truncation now bounds the first entry too, matching its comment.
- own-chromium-tree-kill-refusal.test.ts renamed to
  own-chromium-tree-kill-guard.test.ts, colocated with the module it tests.

Not changed, with reasons:
- Date.now() vs performance.now(): kept. Offsets are computed against
  goneAt = Date.now() in process-gone-recorder; a monotonic clock here would
  make the offsets meaningless. The reviewer verified this and agreed it is
  not a defect.
- app.getAppMetrics() per force-kill remains unbenchmarked. It reads
  in-process browser state rather than enumerating the OS process table, and a
  TTL cache would let a recycled pid slip past the refusal, so it stays
  uncached.
- The ~15 remaining direct process.kill(-pid) sites (browser routes,
  notebooks, automation prechecks, ephemeral VM recipes) are not instrumented.
  Rather than claim coverage this PR does not have, the module doc names them.

claude-command-process.ts crossed the 300-line cap, so terminateClaudeProcess
moved to claude-login-process-termination.ts. No max-lines suppression added.

* fix(crash-reporting): scope the self-kill guard to its real host topology

Round-2 review findings on the own-Chromium tree-kill guard.

BLOCKING 1 — "the own-Chromium refusal is a no-op in the process that issues
the pty-descendant-sweep taskkill". Correct on the mechanism, wrong on the
consequence; REBUTTED in part and documented in full.

Confirmed: the only non-test `setAppEnvironment` installs are
main-process-preflight.ts:177 (Electron) and orcad-entry.ts:84 (Node, whose
`getAppMetrics()` is `[]`); daemon-init-fresh-import.ts is a test harness. So
in the standalone daemon `readOrcaChromiumProcessPids()` is empty and
`admitSelfInitiatedTreeKill` always admits.

But that is not a live hazard. `killWithDescendantSweep` reaches
`terminateWindowsProcessTree` only when `verifyWindowsTreeKillTarget` returns
`own`, and that walks ancestry back to `deps.ownerPid ?? process.pid` — the
KILLING process's pid. In the daemon that is the daemon's pid. Orca's Chromium
processes are children of Electron main, a sibling of the daemon, so their
chain never reaches it: hop 0 lands on main, and within MAX_ANCESTOR_HOPS the
walk dead-ends and returns `foreign`. The reviewer's probe passes
`ownerPid: 1000` with the renderer as a direct child of 1000 — that is the
Electron-main topology, where the AppEnvironment IS installed and the guard DOES
fire, not the daemon's. On an orcad/SSH host there is no Chromium on the box at
all, so `[]` is accurate rather than degraded.

Locked in as tests rather than prose (own-chromium-tree-kill-guard.test.ts):
a renderer classifies `foreign` from a daemon ownerPid with an empty pid set,
and `own` from main's ownerPid with an empty set — the falsifiable pair showing
the pid set is load-bearing in main and nowhere else. Documented the host
coverage in orca-chromium-process-pids.ts and own-chromium-tree-kill-guard.ts.

One genuine hole the finding exposes: `signalProcessTree`'s `taskkillTree` is a
fourth pid-addressed taskkill family (non-blocking item 2), it runs in the
daemon/relay/CLI where the guard cannot run, and it guarded only on
`!child.pid`. Reusing the predicate the codex login teardown already uses, the
win32 branch now refuses a reaped child and falls back to `killRoot` — the same
shape as the existing `!child.pid` branch. That closes the reaped-then-recycled
pid path in every host.

BLOCKING 2 — module doc overstates coverage. Rewritten: the ring is per-process
and its only reader lives in Electron main, so a count on a `render-process-gone`
covers main-issued kills only. Sites are now split into main-only, main-and-
other-hosts (runProcess choke point, POSIX PTY group sweep, Windows Job Object —
which record into a ring nothing reads when they run in the daemon or relay),
and never-instrumented, with the note that a daemon/relay omission is a
diagnostics gap, not a missed suspect, per the topology argument above.

BLOCKING 3 — the three out-of-main instrumentation sites were untested. Added
regression coverage: the runProcess seam on both branches plus the reaped-child
refusal (process-tree-termination.test.ts), the group sweep recording only
groups it actually signalled and skipping an ESRCH group
(posix-pty-process-groups.test.ts), and the Job Object recording the shell pid
only on `terminated` (windows-pty-job.test.ts). Verified red: reverting the
three production files to origin/main fails 7 of the new tests.

BLOCKING 4 — the Windows evidence validates a single-process model. Accepted.
The main2.js arms exercise `pty-descendant-sweep` inside one Electron process;
that models the in-process/degraded daemon and the local PTY provider, not the
standalone daemon. Arm C's "the 449351d6 shape is not producible with the guard"
holds for main-issued kills only. In the daemon the shape is blocked one layer
earlier, by the ancestry check, which the arms do not exercise.

NON-BLOCKING taken: `recordSelfInitiatedTreeKill` moved outside the native
`terminateJob` try in windows-pty-job.ts, so a diagnostics throw can no longer
downgrade a real termination to `unavailable` and escalate callers to a broader
kill; covered by a test. The "all three families" parenthetical is gone with the
doc rewrite. `pnpm build:relay` run: exit 0, all seven targets built.

NON-BLOCKING declined: codex-accounts/service.ts records before the spawn
because a refusal must prevent the spawn — the crumb means "we were about to
kill this pid", which is the artifact worth having; the existing comment already
says so. `app.getAppMetrics()` perf is unbenchmarked and unchanged by this round.

Verification: pnpm tc clean; oxlint clean on touched paths;
check:code-quality:changed 0 new findings; oxfmt applied. 730 tests pass across
shared/child-process, main/crash-reporting, main/pty, main/windows and the guard
and descendant-sweep suites. The 4 failures in providers/git/codex-integration
reproduce on HEAD without these changes.

* fix(crash-reporting): keep the reaped-pid skip from flipping the termination barrier

The win32 hasExited short-circuit correctly avoids taskkill on a pid Windows
may have reissued, but it resolved `true` — verified tree termination. A
taskkill against a reaped pid already resolved `false`, and run-process turns
`true` into barrierTerminationVerified + terminationReporter.report(), which
releases the git admission grant on root exit instead of on `close`. That
admits the next git command while a descendant holding the inherited pipes is
still writing the repo. Resolve `false` so the skip changes only which process
we refuse to signal, not what the barrier claims.
2026-09-03 02:40:58 -07:00
Neil 3a32e084dd perf(renderer): index diff comments, skip no-op hydration, drop duplicate normalizes (#18375)
* perf(renderer): index diff comments, skip no-op hydration, drop duplicate normalizes

* fix(perf): keep tree-path stability hook render-pure for react-doctor

* fix(perf): publish the returned array from the tree-path stability hook

The ref was written with the raw input but read in render to pick the return
value, so it trailed one commit and a wave of content-equal arrays flipped
identity every render — re-firing the uncancellable full-tree git check-ignore
it exists to prevent. Publish `stable` instead, keyed on `[stable]`.

Also drops the hydrateOverrides no-op skip: notifyChange is not a bare wakeup
(it drives getPanesNeedingOverrideFit -> safeFit and the remote viewport
re-claim), and the branch never fires in production anyway.
2026-09-03 02:37:36 -07:00
Neil 860ee73a11 fix(git): parse sparse and cquoted paths on the SSH relay (#18389)
The relay carried its own copies of the worktree-list and unmerged-entry
porcelain parsers, and both had drifted from the desktop originals: the
relay copy had no `sparse` branch, so SSH sparse checkouts were never
marked, and it never C-quote-decoded a conflict path, so a conflicted file
with a space or non-ASCII byte was published under its raw quoted name and
probed as missing.

Move both parsers into src/shared and delete the relay copies, so there is
one implementation each. Type the relay's worktree-list plumbing on
GitWorktreeInfo instead of Record<string, unknown> so a field-copying step
can no longer silently drop a newly parsed field.

`isSparse` is a new optional field on the git.listWorktrees result
(remote-wire-compatibility Rule 1); Git <2.28 omits the porcelain line and
the field stays absent. No new git subcommand or option.

Closes #18280
2026-09-03 02:11:55 -07:00
Neil a9f2fbb684 chore(workspaces): drop the dead workspaceCleanup:hasKillableLocalProcesses IPC (#18386) 2026-09-03 02:00:46 -07:00
Neil b8da193b7a fix(ssh): route the remaining expired-lease readers through the reattach predicate (#18378) 2026-09-03 02:00:42 -07:00
Neil f4b207bc38 docs(ssh): document keep-alive-until-reset as the default grace (#18383) 2026-09-03 01:48:40 -07:00
Neil d05dd8ef50 fix(source-control): route hosted reviews by resolved execution host (#18382)
`ForgeProvider.createReview(repoPath, input, connectionId, options)` and the
`connectionId` on `ForgeProviderRepositoryContext` carried the same collapse the
five prior migrations closed: `string | null` spells "genuinely local", "runtime
host" and "could not resolve" with one value. Because it was decided two layers
up -- `repo.connectionId ?? null` at the `hostedReview:*` IPC handlers and in
`RuntimeHostedReviewCommands` -- a row naming its owner only as
`executionHostId: ssh:<target>` ran the whole review path against this machine's
copy of a remote path (#11163): `git rev-parse`, `git status`, the base-on-remote
ref probe, the upstream divergence read, and `gh`/`glab` with no host flags.

Replace it with a required `ExecutionHostId` threaded from the decision point
through the contract, routed by #18296's `resolveGitRouteForHost`. The parameter
is removed rather than added beside, so all five implementations -- GitLab,
GitHub, Bitbucket, Azure DevOps, Gitea -- and every caller became a compile
error. None of these families carries `@ts-nocheck`, so unlike #18325 that
guarantee is real here; `orca-runtime-file-commands.ts` does, but it only
constructs `RuntimeHostedReviewCommands` with unchanged deps.

Also fixed at the sites:

- The branch cache scoped entries on `connectionId ?? ''`, so two rows at one
  path on different hosts shared one cached review, one backoff deadline and one
  invalidation. Keyed on the resolved host now, as #18377 did for its probe key.
- `hostedReview:create` resolved shared symlink paths and normalized worktree
  paths off the raw field, so an `executionHostId`-only SSH row read `orca.yaml`
  and `resolve()`d a remote POSIX path on the client. Those ask the file-holder
  question -- `getRepoSshConnectionId` -- not the dialable one.
- An SSH host with no provider now refuses inside the git-state layer instead of
  reaching the local branch, keeping "remote and unreachable" distinct from
  "local" (docs/reference/ssh-execution-boundary.md).

`runtime:` is a routing mistake inside `hostedReviewSshConnectionId` -- that
environment's server runs its own git, and the SSH target on its repo row is
nested in that server's namespace, so dialing it here reaches a same-named box of
ours. But store-backed callers ask `getRepoHostedReviewExecutionHostId` first,
which is "what may this client dial" and answers `local` for a `runtime:` row.
That is deliberate and matches #18377: the runtime registration controller only
adopts a `runtime:` stamp onto a row with no `connectionId`
(`runtimeRepoMatchesExecutionHost` refuses to match an SSH row), so the checkout
really is in this process and refusing would regress a runtime server creating
reviews for its own rows.

No wire change. `connectionId` on `CreateHostedReviewArgs`,
`CreateStackedHostedReviewArgs` and `HostedReviewCreationEligibilityArgs` in
src/shared/hosted-review.ts is untouched -- every host already ignores it in
favor of the repo row, and removing it from the request types would only churn
the schema older clients still populate. The main-side eligibility input `Omit`s
it so nothing on this side can read the ambiguous field again.
2026-09-03 01:32:46 -07:00
Jinwoo Hong 573537ecd4 feat(cli): make terminal close the canonical workspace teardown (#18073)
* fix(runtime): recover stale session owners and await retirement

* fix(runtime): preserve session hydration and smoke compatibility

* test(runtime): cover empty and unindexed session owners

* feat(cli): make terminal close the canonical workspace teardown

* fix(preload): align ssh termination result type

* test(runtime): assert folder hydration owner

* fix(runtime): fence legacy terminal stop by worktree host

* fix(preload): reconcile ssh result import with main

* fix(runtime): keep same-id sibling hosts out of workspace close

The stale-owner fallback in the session controller re-routed any worktree whose
catalog partition had no tabs to whichever other partition held tabs. Only
`runtime:` environment ids rotate across relay restarts; `repoId::path` legitimately
repeats across hosts, so an SSH workspace close could retire the local copy's
tabs and resume records, or flip owners mid-close and strand the SSH PTY.

Restrict the fallback to runtime hosts, and pin the session partition once per
workspace close so record clearing targets the partition that owned the tabs.

* test(runtime): give the cross-host close fixture a real resume record

* fix(preload): take main's ssh-bridge import order so the merge stays duplicate-free
2026-09-03 03:58:45 -04:00
Neil 316ec38f67 fix(repos): route icon and remote-identity probes on a resolved execution host (#18377)
`detectRepoIcon`, `detectRepoIconAndUpstream`, `detectGitHubAvatarIcon`,
`detectRepoFileIcon` and `probeGitRemoteIdentity` took a `connectionId`-shaped
parameter threaded down from their callers. That shape spells "runtime host",
"unresolved" and "genuinely local" all as one falsy value, and because it is a
*parameter* each caller decided independently what to pass — a wrong answer was
invisible at the boundary.

Replace it with a required `ExecutionHostId` and route through #18296's
`resolveGitRouteForHost` / `resolveFilesystemRouteForHost`. The parameter is
removed rather than added beside, so every caller became a compile error. No new
resolver, no wire change: nothing these modules return carries a host id.

Fixed at the call sites:

- `repo-git-remote-identity-enrichment` read `repo.connectionId` raw, so a row
  minted with only `executionHostId: ssh:<t>` ran `git remote -v` against this
  machine's copy of the path (#11163), and a `runtime:` row handed its *nested*
  SSH target to this client's dispatch table — a same-named box of ours.
- Its location key had the same collapse, so two rows at one path on different
  hosts shared a probe, an abort controller and a backoff deadline.
- `runtime-repository-fork-backfill` guarded on `repo.connectionId`, so an
  `executionHostId`-only SSH row had its upstream read off the client.

`runtime:` is refused inside the modules (this process does not execute another
environment's git or filesystem), but store-backed callers ask
`getSshTargetIdForExecutionHost` — "what may this client dial" — so a `runtime:`
row keeps the probe this process has always run for it. Registering and cloning
stay `local` on purpose: those controllers do the filesystem work here, whatever
host id is stamped on the row (see `assertCloneHostIsSupported`).
2026-09-03 00:49:36 -07:00
Neil 968dbd905f perf(renderer): take the English catalog and the xterm WebGL addon off the boot graph (#18326)
* perf(renderer): take the English catalog, xterm WebGL addon and emoji data off the boot graph

The renderer's boot graph — the entry chunk plus its 331 modulepreload links,
all fetched and evaluated before first paint — carried three payloads nothing
needs at that moment.

`en.json` (644 KB) was an eager i18next resource, but every renderer string
goes through `translate(key, fallback)` and `en` resolves that inline default,
so most of the catalog was dead weight. The renderer now bundles a generated
`en-runtime-required.json` holding only the 2,583 of 13,828 entries a default
cannot reproduce: plural-suffixed keys, keys whose catalog value differs from a
call site's default, and keys no call site references with a literal default.
`en.json` stays the translator source and the input to the four lazy catalogs.

`@xterm/addon-webgl` (243.6 KB) and `emojibase-data` (170 KB) are now primed
right after the React root renders instead of statically imported. The load
stays eager and `attachWebgl` stays synchronous — it reads the resolved
constructor — so no terminal ever falls back to the DOM renderer for a frame.

`isPluginPanelTabKey`/`isQualifiedPluginKey` move to schema-free sibling
modules, re-exported from `plugin-manifest.ts`. This evicts the plugin manifest
schema graph from the boot chunk but measures ~0 KB, because six other shared
modules still put zod on the boot path.

Boot graph: 332 chunks / 5107.2 KB -> 336 chunks / 4161.5 KB (-945.7 KB, -18.5%).

A new ratchet parses the built index.html and fails if `en.json`,
`@xterm/addon-webgl` or `emojibase-data` is preloaded again; it runs at the end
of every `build:electron-vite`.

* chore(i18n): pin the generated English subset to LF and mark it generated

* fix(i18n): make the runtime-catalog gate merge-robust and prime emoji data in tests

CI builds the merge of a PR with main, so a byte-for-byte comparison against a
committed generated file fails the moment any unrelated PR adds a translate()
call — which is what happened here. The check now asserts the property that
actually matters instead of byte equality: every runtime-required entry is
shipped, and nothing shipped disagrees with en.json. Entries that stopped being
required are dead weight, never a wrong string, so they are reported and
tolerated. Failures now name the offending keys rather than saying "stale".

The generator itself was already deterministic (plain code-unit sort, no
locale collation, order-independent set construction); a test now pins that a
reversed call-site walk produces byte-identical output.

Test fixes for the catalog prune and the deferred emoji load:
- browser-search / NativeChatSupportedAgents asserted key presence on the
  renderer's runtime resource. The durable contract is en.json — the renderer
  deliberately no longer bundles entries a call site default reproduces — so
  they assert against the translator catalog.
- Four emoji tests typed a shortcode in the same tick as mount, before the
  catalog the hook primes on mount resolves. Not reachable by a human; the
  tests now await the prime.

* revert(renderer): keep the emoji shortcode catalog statically imported

Deferring emojibase-data introduced a window that did not exist before: until
the dynamic import settled, getPrimedEmojiShortcodeEntries returned [], so
exactShortcodeIndex built an empty map and replaceCompletedWorkspaceEmojiShortcode
returned null — leaving a typed `:wink:` in the field literally, and persisting
it as the workspace display name.

Pre-change the shared catalog was statically imported, so the first call at any
tick returned full data. The window is reachable by anything that dispatches
input in the same task as the field's mount effect — Playwright/CDP in the e2e
suite and agent automation both do, and the WorktreeMetaDialog test failure was
exactly that, producing 'Feature 😉' instead of 'Feature 😉'.

Nothing that resolves a shortcode can be async without that race, and a wrong
persisted name is not an acceptable trade for 166.7 KB, so the deferral is
reverted rather than papered over in the tests. The boot-graph ratchet drops
its emojibase-data probe and records why.

Boot graph: 5108.9 KB -> 4329.9 KB (-779.0 KB, -15.2%), down from -945.7 KB.

* fix(terminal): make the deferred WebGL addon load recoverable and refit on late attach

Two defects the deferral introduced, neither possible with a static import.

A failed load latched the DOM renderer for the whole session. `.then(onOk,
onError)` settles fulfilled, so the memoized promise was cached forever with a
null constructor: attachWebgl's re-prime got the cached promise back, and
resetTerminalWebglSuggestion — the documented "GPU setting changed, retry" path
— could not clear it either. The rejection path now clears the memo, latches the
queued panes the way a failed construction does so they retry at a recovery
boundary rather than every frame, and caps attempts so a genuinely missing chunk
is not re-fetched forever. The recovery boundary re-arms it.

The queued-attach drain skipped the refit. Every other late-attach path pairs
attach with a refit because the grid was measured under DOM cell metrics and
WebGL floors the device cell width. Post-deferral, openTerminal's attachWebgl
queued and returned, the initial fit rAF then measured DOM metrics and sized the
PTY from them, and the addon attached with no refit — a persistently narrow PTY
and an unpainted right gutter, not a one-frame flicker. Both paths now go
through one attachWebglAndRefit pairing so they cannot diverge again.

Regression tests cover both, and each was verified to fail without its fix.

The addon-load state machine moves to terminal-webgl-addon-loader.ts and the
viewport presentation helpers to pane-viewport-present.ts, keeping
pane-webgl-renderer.ts under the 300-line budget without a suppression.
2026-09-03 00:26:30 -07:00
Neil f2ddf7779f fix(ssh): pick the eligible expired lease, not the first one matching a pane (#18366)
`getRecentExpiredSshLease` selected the first `expired` lease matching the pane
coordinates and left eligibility to the caller. Only `recoverTerminalPane` asked,
and it asks id-qualified, where lease identity `(targetId, ptyId)` already makes
the match unique -- so that check could never fire on a lease a different one
shadowed. The two unqualified callers never asked at all:
`workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate` and
`hasRecentExpiredSshLeasePane` both take a bare `!== null`.

`(worktreeId, tabId, leafId)` is not unique. `supersedeSiblingLeasesForPane`
exists because a pane accumulates leases as it re-leases under new relay ids, and
it stamps `supersededBy` on an already-expired predecessor precisely so the
predecessor stops counting. Inside the 30s SSH_PANE_RECOVERY_GRACE_MS window
those two readers still counted it: a pane whose only recent lease is a
superseded or relay-id-recycled corpse was reported as runtime-owned and
preserved for recovery, and `recoverTerminalPane` then refuses it. Where an
eligible successor also exists, the predecessor is stored first and shadowed it.

Apply the existing `sshRemotePtyLeaseAllowsReattach` inside the selection, so the
reader answers with the first ELIGIBLE orphan or nothing, and all three callers
agree on what `expired` authorizes. `recoverTerminalPane`'s own check becomes
unreachable and is folded into the comment on the branch that now covers it.

Scope: an over-report in headless/mobile reconciliation, not a wrong-route
readoption -- the id-qualified recovery path already refused these leases. No
wire change and no host-semantics change: `expired` still says only that the
client lost its route, and nothing here asserts a remote shell died.

Coverage lives in a `*.test.ts`: config/vitest.config.ts, the config CI runs,
includes only `*.test.ts`, so the orca-runtime-tests/*.spec.ts neighbours would
never execute.
2026-09-02 23:53:09 -07:00
Brennan BensonandMerge Sim d5803bdbc4 feat(ssh): host-stamped remote foreground identity (#18078)
* docs: add SSH agent identity implementation plan

* feat(ssh): host-stamped remote foreground identity

* fix(runtime): preserve unfenced inspect call shape

* perf(ssh): traverse foreground descendants linearly

* fix(ssh): bound retired PTY evidence records

* test(ssh): cover retired incarnation retention

* fix(ssh): make remote process inspection total

* Split SSH identity build hot spots

* Fix process table snapshot module split

* test(ssh): update process inspection expectations

* docs: drop the SSH identity plan from the PR

The design doc does not belong in the product repo; it stays out of the
shipped tree while the implementation carries its own comments.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-02 23:32:41 -07:00
Neil f87119f7ec perf(editor): stop reclassifying the whole markdown document on every render (#18324)
* perf(editor): stop reclassifying the whole markdown document on every render

EditorPanel re-renders from ~18 store subscriptions, and the rich-mode
classifier ran unmemoized in its render body — so an idle git-status poll
rescanned (and TipTap round-tripped) every open markdown tab.

- Memoize `getMarkdownRichModeEligibility` on (content, sizeOverridden).
  Idle 60 s with one markdown tab: 31 -> 1 classifier calls, 31 -> 1
  round-trip calls. Render-model self time for a 100 KB .md with HTML:
  6.485 ms -> 0.004 ms per render.
- Drop the React effect that mirrored `content` into the doc-link decoration
  refresh; the controller's own `onDidChangeModelContent` listener already
  covers it (and catches programmatic edits). 800 -> 400 debounce timer ops
  per 200 keystrokes, same decorations.
- Scan doc-link decorations by line offsets instead of per-line substrings,
  reusing the allocation-free `forEachLine` from the conflict decorations.
  600 KB / 46k lines: 50,853 -> 4,623 string allocations per scan,
  34.1 ms -> 20.3 ms per scan.
- Replace the per-keystroke double `trimEnd()` dirty check with a
  trimmed-length probe plus one native prefix compare: 400 -> 0
  full-document copies (40 MB -> 0 bytes) per 200 keystrokes. Move
  fileContents/diffContents behind refs so the change callback identity
  stops churning on every content load.

No behavior change: rich-vs-source selection, decorations, and the dirty
dot are all covered by equivalence tests against the previous code.

* fix(editor): move the dirty-check content refs out of the render body

React Doctor's `no-ref-current-in-render` flagged the `fileContents` /
`diffContents` ref writes added for the stabilized change callback, and it
was right on substance: a render React discards would still have moved the
dirty-check baseline, so a later keystroke could be compared against content
from a render that never committed.

Assign both refs in a `useLayoutEffect` instead — the same latest-value
pattern `useIpynbDocumentEditing` already uses. Layout effects only run for
committed renders and land before any input event can reach the handler, so
the baseline is always the committed one.

The handler and its refs move into `use-editor-content-change-handler.ts`;
that keeps `EditorPanel.tsx` under the 400-line cap (no `max-lines` bump) and
puts the draft write, the dirty comparison and their inputs in one place.

Callback identity stays stable (1 distinct identity across 30 idle renders)
and every measured number is unchanged: 1 classifier call and 1 round-trip
call per 60 s idle, ~0.005 ms render-model self time for a 100 KB .md with
HTML. Also asserts the reverse direction of the reload case — the stable
handler marks the file dirty when handed the pre-reload content.

* fix(editor): keep the rich-mode fallback banner localized behind the memo

The memo keyed on `(content, sizeOverridden)`, but the value it cached was
not a pure function of those two: `unsupportedMessage` comes from a matcher
`get message()` accessor that calls `translate()` at access time, so the
active UI language is a third, ambient input. Caching the resolved string
froze the banner in whichever language was active at first classification —
visible when switching Settings → UI language, and at startup for non-English
users because `I18nProvider` applies the persisted language from an effect,
after the settings-driven render has already classified.

Adding the language to the cache key would only work until the next ambient
input. Instead, split the classifier: `getMarkdownRichModeEligibilityDecision`
returns the genuinely pure part (`exceedsSizeLimit` plus which matcher fired)
and is what the cache stores, while `resolveMarkdownRichModeUnsupportedMessage`
reads the matcher's getter per read. `getMarkdownRichModeEligibility` keeps its
old signature as a thin composition of the two.

Cost of re-resolving per read is one `i18n.t` on documents that show a banner
and nothing at all on documents that do not (a null reason short-circuits).
Render-model self time for a 100 KB .md with HTML moves 0.005 ms -> 0.011 ms,
against a 6.485 ms pre-PR baseline; classification still runs once per content
change (1 decision and 1 round-trip across 30 idle git-status ticks).

Two regression tests, both confirmed to fail against a string-caching cache:
a unit test asserting a cache hit follows `changeLanguage('ja')`, and an
EditorPanel test that renders the banner from a reference-link document,
switches the language, forces an idle store write, and asserts the Japanese
text while the decision stays cached.
2026-09-02 23:31:20 -07:00
Neil fe5efb24c8 fix(cleanup): route workspace cleanup by resolved execution host, not repo connectionId (#18358)
The workspace-cleanup scan threaded `provider: IGitProvider | null`, derived from a
raw `repo.connectionId` read, through listing, activity and git evidence. That `null`
spelled "this is local", "the host is remote but unreachable" and "the host is a
runtime environment" with one value, so a row naming its owner only as
`executionHostId: 'ssh:<target>'` listed worktrees, statted paths and ran `git status`
for a *remote* checkout on this client (#11163).

The three sites had to move together: the `provider!` assertions in
workspace-cleanup-git-evidence.ts were sound only because they re-read the same field
that workspace-cleanup-worktree-listing.ts used to decide whether `provider` was
populated. Migrating one alone turns them into crashes.

Routing now goes through the shared resolution layer -- `getRepoExecutionHostId` for
the repo that produces the listing, `getWorktreeExecutionHostId` for the workspace's
own host -- into `resolveGitRouteForHost` from #18296's host-keyed dispatch. The
ambiguous carrier is removed rather than supplemented, so every reader became a
compile error; unlike #18325's family, workspace-cleanup carries no `@ts-nocheck`, so
that guarantee is real here.

`runtime:<env>` is not a route variant. Its Git runs on that environment's own server
and the SSH target on its repo row is that server's nested one, addressable only as
(environmentId, targetId); handing it to this client's SSH table dials a same-named
target in the wrong namespace. It throws, matching workspace-space-repo-scan and
repos:listForExecutionHost.

No wire change: `WorkspaceCleanupCandidate` (including `connectionId` and
`executionHostId`) and the workspaceCleanup RPC UI-state schema are untouched.
2026-09-02 23:29:23 -07:00
Neil 67741769b8 perf(browser-pane): share one rAF loop across client-hosted page overlays (#18313)
* perf(browser-pane): share one visibility-gated rAF loop across client-hosted page overlays

Each shown client-hosted browser page started its own permanent, ungated
requestAnimationFrame loop whose callback forced a layout flush via
getBoundingClientRect, so N shown hosts cost N loops forever, including
while the document was hidden.

Registers every host with one shared driver instead: one rAF callback per
frame syncs all registered hosts, the loop starts on the first registration
and stops on the last, and it pauses while the document is hidden with an
immediate resync of every host before it resumes.

* fix(browser-pane): drop the hidden-document gate from the shared overlay position loop

Measured on Electron 43 / macOS: a hidden, minimized or fully occluded window
reports visibilityState 'hidden' AND already runs 0 rAF callbacks/s, so the gate
saved nothing. Its only live effect would be in the wedged-occlusion state the
renderer already works around, where it would freeze every overlay for good.
Also release a retained page's position sync when the registry tears the page
down, instead of waiting for the pane's own detach that may never run.

* fix(browser-pane): isolate a throwing host from the shared overlay position loop

One loop now serves every shown client-hosted overlay, so an exception from any
single host's viewport sync escaped runFrame before it rescheduled and stopped
every other overlay tracking its pane, with nothing left to restart it: a pane
that merely moves fires no resize or scroll event.

Each sync now runs isolated and the reschedule is unconditional, so a failing
host is skipped and reported once instead of sixty times a second.
2026-09-02 23:25:10 -07:00
Brennan BensonandMerge Sim c4f335b49d feat(native-chat): unify local agent entrypoint routing (#18248)
* feat(native-chat): unify local agent entrypoint routing

* fix(native-chat): satisfy entrypoint routing quality gates

* test(native-chat): keep activation caller census complete

* fix(native-chat): honor selected runtime platform in routing

* fix(native-chat): pass runtime platform through full creation

* fix(native-chat): include runtime platform dependency

* fix(native-chat): preserve client platform routing gate

* fix(native-chat): preserve trust and coalesced prompts

* fix(native-chat): preserve target tab surface on late activation

* fix(native-chat): retain quick-command history for structured tabs

* test(native-chat): cover structured quick-command group history

* fix(native-chat): ignore resolved default agent args in routing

* test(native-chat): cover default agent args classification

* fix(native-chat): keep direct launch within lint limits

* fix(native-chat): clear abandoned launch outbox

* fix: cancel dismissed structured worktree launches

* fix(native-chat): preserve coalesced launch recovery

* fix(native-chat): reconcile uncertain worktree launches

* fix(native-chat): reuse uncertain launch caller

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-02 23:23:06 -07:00
Neil 94be54d16c perf(file-explorer): stop rebuilding the whole visible tree twice per directory refresh (#18319)
* perf(file-explorer): stop rebuilding the whole visible tree twice per directory refresh

The per-directory loading flag moves out of `dirCache` into a sibling
`Set<string>`, so a `dirCache` identity change now means "children changed".
Every identity change re-ran `getFileExplorerIgnoredQueryRelativePaths` (full
recursive walk) and `createVisibleFileExplorerRowProjection` (full flatten, new
Map, new array identity cascading into virtual rows, selection, keyboard nav and
the name filter) over the whole visible tree — and half of those rebuilds
produced a byte-identical row set.

Also in this change:
- `refreshFileExplorerExpandedDirs` no longer pre-marks every expanded dir in
  `dirCache`; the 13 progressive commits stay.
- `flushBatch` paces its `fs.stat` fanout at 8 (was up to 5,000 concurrent onto
  libuv's 4-thread pool), matching parcel-watcher-event-delivery.ts.
- The editor external-watch loop bails before allocating a notification for a
  path no open file matches.
- `createCachedDirPathIndex` is built lazily, only when a direct
  `dirPath in cache` lookup misses.

* fix(file-explorer): keep the loading-dirs ref out of the render body

React Doctor's no-ref-current-in-render flagged the render-body mirror, and it
was right: a render React discards would still have mutated the ref. The ref is
now authoritative and written only from callbacks, with one updater that moves
the ref and the state together.

Side effect, in the safe direction: loadDir's in-flight guard now sees a mark the
moment it is made instead of one commit later, so a second non-forced read of a
directory already being read is deduped rather than started and then superseded.
Forced reads (refreshDir, refreshTree) bypass the guard and are unaffected.

Also moves the in-flight check out of decideExpandedDirLoad and into the
expansion effect that owns the fan-out, restoring the two-argument signature.
This clears the no-pass-data-to-parent warning the three-argument call had
dragged onto a changed line, and it keeps the pure staleness decision pure.
2026-09-02 23:19:59 -07:00
Neil ddb13a10f7 perf(agent-status): memoize pane routing, cache the freshness minimum, stage one clone per transaction (#18323) 2026-09-02 23:19:38 -07:00
Neil df420285b0 perf(persistence): stop rewriting redundant bytes in the profile store (#18317)
Two kinds of byte in orca-data.json were provably redundant. Both are paid on
every debounced save (full re-serialize) and every launch (full re-parse).

1. The renderer's host split handed one global-field template to EVERY host, so
   local's browserUrlHistory/workspaceDocHistory were copied verbatim into each
   non-local partition. That is a write-side regression undoing #18161's
   load-time drop: the load path removed the replicas, the next full snapshot
   write put them back. Non-local slices now get a template without the fields
   the merge only ever reads off 'local', and both the host write and the
   serializer strip the residue.

2. mergeWorktreeMetaForWrite materializes all ten linked* slots plus
   isArchived/isPinned on every metadata row, so a 1,200-workspace store carried
   ~534 KB of "field":null pairs across worktreeMeta and worktreeMetaByIdentity.
   The serializer omits slots still at their default and
   normalizeWorktreeLinkedItemMetadata re-fills them at load, so in-memory state
   is unchanged.

Measured on a fixture sized like the reporting install (10 hosts, 1,200 metadata
rows, 200 history entries): 1,445,276 -> 643,238 bytes per save (-55.5%),
164,250 -> 3,411 bytes structured-cloned per persistWorkspaceSessionByHost
across 9 non-local hosts, launch JSON.parse 2.00 ms -> 1.37 ms.
2026-09-02 23:19:28 -07:00
Neil 7ea213cf8c perf(main): remove four per-chunk/per-waiter hot-path costs in PTY and terminal-wait (#18315)
Four independent wastes on the main process, none of which changes behavior:

- One shared 2s sweep replaces one setInterval per terminal-wait waiter. 20
  waiters allocated 20 handles and 10 main wakeups/s independent of output;
  now 1 handle and 0.5 wakeups/s. Same cadence, same per-waiter checks in the
  same order, same resolve semantics; the foregroundPollInFlight latch moved
  into the waiter's poll entry unchanged and each entry still interleaves its
  own foreground read, so one slow ps cannot delay another waiter.

- SIGWINCH's `ps` for Orca's own row is memoized. It reads this process's
  controlling tty, which is invariant for the process lifetime, and feeds
  exactly one guard. Exec count per 4-pane tab switch drops 16 -> 8. The call
  stays synchronous: making it async would reorder SIGWINCH against subsequent
  writes.

- The wait-blocked carry retains chunks with a running char count instead of
  concatenating and re-slicing a 256KB window on every chunk, and joins once
  at scan time. runWaitBlockedCheck receives a byte-identical `appended`.

- maxUpwardCursorReach no longer compiles a RegExp per redraw chunk, and
  containsTerminalVerticalLineControl walks with charCodeAt instead of minting
  a one-char string per position.
2026-09-02 23:19:18 -07:00
Neil c09d89b77a perf(terminal): cut per-pane store listeners from 48 to 17 (#18322)
TerminalPane mounts once per retained tab and zustand visits every listener
synchronously per publication, so the per-pane subscription count multiplies
agent-status burn (docs/reference/renderer-agent-status-performance.md).

- Bind the 27 store actions the controller dispatches once through getState()
  instead of one subscription each. Action identities are fixed at store build
  time, so those subscriptions could never fire.
- Read the five unified-tab fields the chat state needs through one shallow
  selector instead of five subscriptions that each re-ran the same lookup.
- Memoize selectTerminalPaneHostState on published-state identity plus
  worktreeId. useShallow suppressed the render, not the selector, so every
  publication re-resolved the execution host and allocated a fresh 7-key object
  for every mounted pane.
- Reconcile cold-park recheck timers by absolute deadline instead of clearing
  and re-arming all of them on each effect run. Deadlines are absolute, so a
  title-only write recomputed the same instant; the park instant is unchanged.
2026-09-02 23:17:33 -07:00
Neil 019d2a3999 perf(combined-diff): stop rebuilding whole-section derived state on every section load (#18321)
* perf(combined-diff): stop rebuilding whole-section derived state on every section load

Opening a 500-file review committed setSections once per loaded file, and six
independent consumers each did a full pass over the new array: the scroll-anchor
restore signal rebuilt one template string per section and joined all N, the
virtualized anchor hook rebuilt a key -> index Map, the section index map and the
viewed-key set re-scanned by key, TanStack re-ran a template-string getItemKey per
index per measurement, and the toolbar re-scanned for all-collapsed.

One incremental scan (useCombinedDiffSectionRowKeys) now produces the pre-built
virtualizer row keys, a structural revision token for the restore signal, and the
all-collapsed flag; unchanged rows settle on a pointer compare. The anchor hook
takes the section index map the tree already maintains instead of building a second
one. The comment decorator memoizes its commentable-line join and both PR call
sites pass a stable callback. The combined-diff file tree no longer filters,
groups or flattens while collapsed.

Measured at N=500 (progressive load of one review): section-key reads
2,001,000 -> 3,000; transient key/signal strings 750,500 -> 1,499 (118.9 MB ->
0.18 MB of string bytes); derived-value CPU 206 ms -> 3.9 ms. Collapsed file
tree: 1,490 entry-path reads per render -> 0. commentableLineKey joins per 100
renders: 100 -> 1.

* fix(combined-diff): commit the section row-key cache instead of writing it during render

React Doctor flagged the incremental scan's ref writes: a discarded render seeded
the cache, so a later render could patch against sections that never committed.
The scan is now a pure function of (previous cache, generation, sections) and the
cache is written in a layout effect — the same committed-write pattern
useCombinedDiffSectionIndexMap already uses. The scaling test's hook harness takes
its fake refs and callbacks as stable module constants so it stops reporting
recreated effect dependencies.

No measured change: section-key reads across a 500-section progressive load stay
at 3,000 (mount 1,000).
2026-09-02 23:17:29 -07:00
Neil 337e7682dc perf(sidebar): memoize the lineage ancestor index and precompute sort labels (#18318)
The sidebar rebuilt its lineage projection on every store write and
re-derived both sort labels on every comparison.

`computeVisibleWorktrees` built `lineageAncestorById` as a fresh Map per
call and handed it to `getCyclicProjectedWorktreeLineageIds`, whose memo is
keyed on that map's identity — a guaranteed 100% miss, so every PTY spawn,
tab open/close and agent-status transition re-walked all workspaces and
re-ran cycle detection. Both that index and the `sortedIds` rank index now
come from module-level WeakMaps keyed on the store collections that are
already identity-stable.

The index still excludes archived rows and still resolves a two-host id
collision last-wins, exactly as the per-call Map did; keying on the store's
own worktree map would let an archived parent resolve as a valid ancestor.

`compareWorktreeSortLabel` is the final tiebreaker in all five sort modes
and derived both labels per comparison. Labels are now precomputed once per
sort into a row-keyed Map — row-keyed, not id-keyed, so a two-host id
collision cannot hand one row the other's label.

400 workspaces: lineage rebuilds per 100 store writes 100 -> 0;
computeVisibleWorktrees 0.114 -> 0.069 ms/call; name sort 0.258 -> 0.177 ms.
2026-09-02 23:14:21 -07:00
Neil 37694d9896 fix(memory): close two per-id map reaper gaps and ratchet the pty-exit reaper (#18320)
`onPtyExit` deletes ~25 per-PTY maps but never `ptyLifecycleGenerationById`,
so every PTY that ever ran left one entry behind for the life of the main
process. Safe to delete because `getPtyLifecycleGeneration` lazily mints from
the monotonic `nextPtyLifecycleGeneration` — a re-read after the delete returns
a strictly newer number, never a reused one, so no stale frame can be accepted.

`warnedLostHandlerPtyIds` outlived the buffered data it describes when the LRU
cap evicted that data, and because the warn is once-per-id it also suppressed a
legitimate re-warn on a fresh accumulation for that same id.

`ambiguousOwnerWarnedWorktreeIds` was a module-scope Set with no delete
anywhere, while both worktree teardown paths prune ~20 sibling collections.
Not pruning also suppressed a legitimate re-warn for a recreated worktree id.

Adds a ratchet that reads every per-PTY-keyed collection off a real runtime
instance and requires each to be deleted by the reaper, cleaned by a helper the
reaper calls (verified against that helper's source), self-clearing per
in-flight operation, or explicitly justified as retained.
2026-09-02 23:14:17 -07:00
Neil 900aa52d6c perf(history-gc): drop 2 syscalls per history dir + 1 per file from startup GC (#18314)
The GC pass that runs ~10s after every launch stat'd every entry in the
terminal-history root just to test isDirectory(), then readdir'd each
directory and stat'd every file inside it to accumulate `totalSizeKB` — a
field whose only consumer was one `console.log`.

The root listing now uses `readdir(root, { withFileTypes: true })` and reads
`dirent.isDirectory()`, falling back to `stat` only for symlinks so a
symlinked history directory keeps resolving through its target. The size
estimation and `totalSizeKB` are gone, along with the log field.

On a 50-dir x 3-file fixture: 301 readdir+stat calls -> 51. Extrapolated to
the reported 2,781-dir / 6,697-file corpus: 13,906 -> 2,782.
2026-09-02 23:13:57 -07:00
Rudimar Ronsoni e48d217670 fix(claude): match keychain account to Claude Code (#16673)
SSO $USER values like first@example.com fail Claude Code's
account charset, so login writes claude-code-user while Orca
looked up the email. Fixes stablyai/orca#12857.
2026-09-02 23:08:56 -07:00
Neil 94fcbe1908 fix(relay): stop reviving a PTY into a directory that is gone from the host (#18351)
`reviveEntry` re-resolves and re-bounds every field it takes from serialized
state -- shell override, WSL distro, envToDelete, TERM, history isolation, the
credential guard -- except `cwd`, which went straight to `node-pty`. A serialized
cwd only proves the directory existed when the client wrote it down: a worktree
removed while the relay was down makes it a dead path.

node-pty does not report that as a spawn error on POSIX. The child `chdir`s after
the fork and `_exit(1)`s, so the pane revives already dead with no output and no
diagnosis. On Windows `CreateProcess` fails instead, and the throw escapes
`reviveEntry` (there is no shell override to degrade), then escapes `revive`'s
loop, which has a `finally` but no `catch` -- so one dead directory costs every
later entry in the batch its state.

Skip that one entry instead, which is the call `reviveEntry` already makes for a
shell override that can no longer spawn: substituting a different directory is
the defect the serialized value exists to prevent, so dropping one pane is the
honest outcome. The check runs inside `reviveEntry`, after `beginPtyCreation`, so
the worktree-removal fence still sees the serialized path -- a removal in flight
leaves it partly present, and statting it must not be what decides. Skipped
entirely for a WSL shell, whose cwd lives in a guest that never stats on this
host, matching the `executesOnRelayFilesystem` boundary `requireRelaySpawnCwd`
already honours.

Fixture paths in the revive tests move to a real directory: `/repo` and
`C:\repo` never existed, so under the new check those panes would be skipped
before the shell-override and session-cap behaviour under test could run.
2026-09-02 22:57:27 -07:00
Jinjing b52614ce28 treat domain paths as URLs, not new files (#18340)
Use the public suffix list to identify real domains in queries like
`example.com/profile`. When a domain is recognized, treat the path
component as a URL path rather than a file path, preventing
accidental file creation with domain-like names.
2026-09-02 22:51:23 -07:00
Neil 720c3299ba fix(ssh): require a host death certificate before recreating a pane, and unstick expired leases (#18013)
* fix(ssh): match an expired lease on where its leaf lives now, not its frozen tab

A lease freezes tabId at write time, but detachTerminalPaneToTab moves a live
pane, so the stored tab is the one the pane LEFT. getRecentExpiredSshLease
required lease.tabId === tabId, which is wrong in both directions: a viewer on a
stale mirror matched under the abandoned coordinates (and resolvePersistedStable
PaneOwner then reads an empty layout for that tab, so adoptStablePane is skipped
entirely and a fresh shell is spawned over a possibly-live one, binding the same
leaf in two tabs), while a viewer using the pane's real coordinates matched
nothing and got terminal_not_recoverable.

Resolve the leaf's current tab the way restoreReattachedPtyRuntime already does
and compare against that, falling back to the frozen tabId only when nothing can
say where the leaf lives. Both workspace partitions are read because SSH spawns
bind into ssh:<target> while reattach binds into local.

* fix(ssh): let a proven reattach take an expired lease back to attached

#17965 authorized reattach from `expired` but the state machine refused the
transition back, so a lease that reattached and proved itself alive stayed
`expired` forever. That silently exempted a demonstrably running remote shell
from `ssh:reset` (skips `expired`), from the SSH_TERMINATE_RECONNECT_REQUIRED
ownership fence in `ssh:terminateSessions` (marks it not-owned), and from the
quit-time `detached` sweep, and made it permanently ineligible to win
supersession so its own successors never retired their predecessors.

Only the id-qualified caller carries per-pty proof: markSshRemotePtyLeases
AttachedAsync is fed the relay's `attachedLeaseIds`, so an unqualified bulk mark
over a whole target still cannot revive `expired`. `terminated` stays absorbing.
Re-entering `attached` drops supersededBy/relayIdRecycled, since route
retirement belongs to the shell that lost the pane and this one just proved it
is not that shell — the same invariant upsertSshRemotePtyLease enforces.

* fix(ssh): make the pane-recovery liveness gate refuse without positive evidence of life

The gate refused only `live` and `unverifiable` and passed on `null` — but the
register is an in-memory Map, so `null` is equally what a fresh app start, a
never-asked host and a certified death look like. Absence of evidence was
reading as authorization to spawn a shell over a possibly-live remote process:
`!pty.connected` is cleared for every PTY a dropped relay owned, and `expired`
only ever says the CLIENT lost its route.

- `exited` is now RETAINED rather than deleted, so the register is three-valued
  in the map as well as in the type. Its one writer is a host-delivered exit
  frame — an exit with a real code, or an explicit `hostExitConfirmed` — which
  records the certificate instead of merely dropping the doubt.
- `recoverTerminalPane` refuses on `live` and `unverifiable`, and deliberately
  does NOT demand a positive `exited`. The only answer that ever reaches this
  gate is a reachable relay reporting no such id, and that is a union: pty.attach
  throws not-found for an unknown id with no liveness check, and a relay restart
  makes every previously minted id unknown (ids carry a per-start
  `ptyIdMintEpoch`). No writer of `exited` co-occurs with a reattachable
  `expired` lease either — a host-delivered exit frame tombstones the lease
  `terminated` — so requiring one would close the gate permanently.
- `handlePtyReattachFailure`'s not-found branch publishes `code: -1` to the
  renderer and does not call `runtime.onPtyExit`. The relay's not-found answer is
  not a death certificate, and #17963's ratchet on the same branch pins that.
- The inventory's `observed === false` hunk keeps dropping doubt rather than
  asserting a death: `pty.listProcesses` returns the relay's CURRENT session map,
  so a restarted relay omits every previously minted id whether or not those
  shells died — the same union, one hop away.

A live or unprovable pane refuses; a disowned one still recovers. No wire change.

The gate's ratchets live in terminal-pane-recovery-liveness-gate.test.ts:
config/vitest.config.ts — the config CI runs — matches only `*.test.ts`, so cases
placed under orca-runtime-tests/*.spec.ts would never execute.

* fix(ssh): gate paired-viewer pane recovery on the narrowed session-gone predicate

isSshSessionGoneError landed on the IPC transport, which never calls
terminal.recoverPane. The one caller that does — recoverExpiredHostPane in the
paired-viewer transport — still triggered on a bare SSH_SESSION_EXPIRED
substring, so the identity-mismatch reply (the relay found a LIVE PTY under that
id owned by another pane, which is evidence of presence) still asked the HUB to
replace the pane, putting a second agent on one transcript. Main already refuses
the respawn on that same reply; this makes the two agree.

A pane whose shell genuinely died is unaffected: plain SSH_SESSION_EXPIRED still
matches. The mismatch reply now surfaces as an error instead of a respawn.

* test(persistence): update the reattach ratchet for expired-lease reclaim

markSshRemotePtyLeasesAttachedAsync is id-qualified, so a named pty that
proved itself alive now returns to attached instead of staying expired.
2026-09-02 22:31:59 -07:00