Commit Graph
10011 Commits
Author SHA1 Message Date
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
Neil 08c7152ab6 fix(ssh): compare a lease's relay pty id against the pane's app id (#17969)
`getRecentExpiredSshLease` compared the stored lease ptyId (relay form,
written through `toStoredPtyId` -> `toRelaySshPtyId`) raw against the
runtime's app-form `pty.ptyId`, so `'pty-3' === 'ssh:target@@pty-3'` never
held and `recoverTerminalPane` refused every real SSH pane. Normalize with
the same tolerant helper the binding reader already uses, now shared as
`toComparableRelaySshPtyId`.

Switching the path on is only safe on top of #17957 (respawn gated on the
runtime liveness verdict), #17965 (`expired` no longer withdraws bindings)
and #17966 (supersession and id recycling carry their own marks).
`recoverTerminalPane` additionally refuses a lease those marks disqualify,
so it acts only on an `expired` lease that means "reattach gave up".

The path's outcome is a reattach, not a respawn: `createTerminal` calls
`adoptStablePane` first, which attaches attach-only to the retained
binding and only falls through to a fresh shell once the host itself
answers that the PTY is absent.
2026-09-02 21:48:17 -07:00
Neil f2e95e7860 fix(ssh): separate a superseded lease from an orphan so reattach can tell them apart (#17966)
`expired` was one word doing two unrelated jobs — "a newer lease won this pane"
and "reattach lost contact" — so `reattachKnownPtys` had to exclude all of them.
That kept the 2 -> 19 -> 20 fan-out fixed at the cost of never bulk-reattaching a
genuine orphan; those recovered only through the slower `adoptStablePane` path.

The blocker cited in #17965 does not apply. The STA-3077 note guards
`upsertSshRemotePtyLease`'s match against a RECYCLED `pty-N` after a relay
restart. `supersedeSiblingLeasesForPane` is a different path and already holds
`winner.ptyId` when it expires a predecessor, so recording which lease won needs
no relay-start identity.

`SshRemotePtyLease` gains two optional marks, each meaning exactly one thing:

- `supersededBy` — the winner's stored-form ptyId, written only by supersession.
- `relayIdRecycled` — written only by the pending-stop replay's
  `relay-id-recycled` retirement. That retirement wrote `expired` *purely* to
  keep the lease out of the reattach that runs one step later ("hands the user's
  old pane to whatever process now holds the recycled id"), and the reattach
  fences on paneKey/tabId, never on incarnation. Relaxing the filter without
  this would have silently reopened that hole.

Bulk reattach now skips a lease carrying either mark and re-adopts the rest, via
one shared `sshRemotePtyLeaseAllowsReattach`. `terminated` is untouched.

Recycled-id safety: both marks are dropped whenever the id is re-upserted
`attached`/`detached`, so a relay that renumbered onto a new shell cannot inherit
its predecessor's mark. Supersession also stamps an ALREADY-expired predecessor
for the same pane — same evidence, and it is what bounds the reattach set, since
otherwise every past orphan for that pane would stay reattachable forever. Its
`updatedAt` deliberately stays put: bumping it would make a stale lease look
recent to `getRecentExpiredSshLease`.

Persistence: the lease loader is a strict whitelist, so both fields are named in
`normalizeSshRemotePtyLease` or they would be stripped on every launch. Absence
reads as "orphan", which is the only thing an older build could have meant, and
an older build ignores keys it has never heard of (remote-wire Rule 1).
2026-09-02 21:39:08 -07:00
Neil e4c279fa60 fix(ssh): let an expired lease reattach its orphan instead of stranding it (#17965)
* fix(ssh): let an expired lease permit a reattach instead of unbinding the pane

`expired` never means the remote shell exited. Every writer records that the
CLIENT lost its route — a superseded sibling, a recycled relay id, a
persistPtyBinding refusal made *after* pty.attach proved the shell alive, a
failed reattach indistinguishable from a relay restart, a relay reset whose
kill may not have landed. docs/reference/ssh-execution-boundary.md grades all
of those `unverifiable`.

Three readers treated it as death, and together they made the pane unable to
reach a process that is still running:

- `isRestorablePtyBinding` / `hasRestorableSshRemotePtyLease` refused to replay
  a durable binding a renderer snapshot had omitted.
- `markSshRemotePtyLease(s)` wiped the persisted pane->pty binding, which is
  what makes `resolvePersistedStablePaneOwner` return null, `adoptStablePane`
  give up, and `createTerminal` cold-spawn a replacement. The user's terminal
  comes back empty and the running job is orphaned and invisible.

Only `terminated` now withdraws a binding: it is the operator-close state
(`ssh:terminateSessions`) and the one written after a host-acknowledged stop.

This authorizes a reattach ATTEMPT, never a respawn, so #17957's gates are
untouched and in fact fire less often — where the pane previously went straight
to a fresh spawn it now attaches first. A genuinely dead shell still converges:
`attachStablePaneOwner` retires the binding on `isPtyAlreadyGoneError` (the
relay's own absence answer, not a message match) and falls through to a fresh
spawn, so no pane retries forever.

Supersession keeps its own binding scrub in `supersedeSiblingLeasesForPane`,
where a NEWER lease for the same pane is the evidence — the 2 -> 19 -> 20
reattach fan-out stays fixed.

* test(persistence): split SSH remote PTY binding partition cases into their own file
2026-09-02 21:35:28 -07:00
Neil 8cf6e12009 fix(wsl): stop runWslProcess inheriting a removable spawn directory (#17837)
#17834 named an explicit Windows cwd for the wsl.exe spawns in
wsl-command-resolution and wsl.ts, but runWslProcess -- which 25 production
files route through, the bulk of WSL spawns -- still passed none, so #16463
survived on the majority path: an inherited cwd that is later deleted (the
worktree Orca launched from) fails every subsequent spawn for the session.

The test asserted `cwd` was undefined, so the fix turned it red. That
assertion was over-tight rather than a contract this violates. Its name and
the production comment both state the real invariant -- "that is a *Windows*
directory for wsl.exe", i.e. the GUEST path must never leak into it -- and
withGuestCwd still cds inside the guest, so the invariant holds. Undefined was
a proxy for it, and an inherited directory satisfies the proxy while being the
bug. Retargeted to assert what is actually meant: not the guest path, and
present.

Deliberately not silent: the salvage agent hit this, reverted rather than
overrule a documented contract in a module it was not sent to change, and
escalated. That was the right call to escalate; this is the answer.
2026-09-02 21:33:45 -07:00
Neil 57681ecd09 fix(remote): resolve the spawn cwd, the node manager dir, the vault host and the scrollback seed (#17952)
* fix(remote): resolve workspace cwd, mise Node, host scope, and TUI scrollback honestly

#15296 relay: a folder workspace id (`folder:<uuid>`) carries no path, so the
worktree-id split yielded nothing and $HOME silently won. Resolve the spawn cwd
through worktreeId -> ORCA_WORKSPACE_ROOT -> host default, and refuse an agent
spawn outright when a folder workspace names a root this host cannot resolve.

#11733 ssh: generalize the NVM dotfile scrape into `orca_dotfile_dirs` and drive
mise off `MISE_DATA_DIR` / `XDG_DATA_HOME` instead of a hardcoded
`$HOME/.local/share/mise`.

#13713 ai-vault: an unresolvable workspace host is `unverifiable`, not local.
Widen the default scope to every host rather than scanning the client's own
history and reporting "No agent sessions found".

#6106 terminal: hydration asked the renderer for `scrollback: 0` while an
alt-screen TUI was up, which drops the normal buffer's shell history rather than
the TUI bytes. Drop the flag; readers already split the two buffers apart.

* fix(remote): stop the relay answering host questions for a guest execution host

Three findings from review of the spawn-cwd resolver, all the same shape: a path
question answered against the wrong host, or with the wrong key.

- resolveRelaySpawnCwd refused an agent launch whenever a folder workspace named
  a root that did not stat on the relay. But relayHostDirectoryExists stats the
  relay's *own* filesystem, and the relay supports WSL shells, so a folder
  workspace on a Windows relay launching into WSL now threw where it previously
  spawned -- contradicting the function's own doc comment, which says an absent
  path for that exact host pair is a miss, not a refusal. Thread the shell's
  execution host in and demote the refusal to a miss when the spawn does not run
  on the relay's filesystem.

- requireRelaySpawnCwd's doc claims both call sites route through one resolver
  so the fence can never be keyed on a directory the spawn won't use, but the
  fence key was still computed with the non-stripping splitWorktreeId while the
  cwd used splitWorktreeIdForFilesystem. For a `::workspace:<uuid>` id those
  disagree by construction, in adjacent lines: the removal fence guarded a path
  no spawn ever enters. Same defect in shutdownForWorktreePath and the revive
  path; all three now use the filesystem split.

- The remote Node probe expanded `$HOME` and `~/` prefixes out of a dotfile
  assignment but not `$XDG_DATA_HOME`, so `MISE_DATA_DIR=$XDG_DATA_HOME/...`
  was used as a literal directory name. Add the case arm, defaulting to the
  POSIX `$HOME/.local/share` the seed value already uses -- sshd's exec channel
  usually has no XDG_DATA_HOME at all.
2026-09-02 21:33:41 -07:00
Neil cc66d6e900 fix(remote): stop a colliding path key, a dead conflict state, and a live-PTY removal from losing tabs (#17948)
* fix(ssh): retain remote sessions across late catalogs, path collisions, and PTY rotation

Three losses in the "remote session state never reconciled" cluster, one rule:
absence from a client-side set, or a stale client-side expectation, is
`unverifiable` by construction and can never authorise removal.

#12902 / #15484 — a direct-SSH snapshot whose host paths the local worktree
catalog cannot place yet leaves the target in `conflict`, which suppresses
uploads and holds terminal authority at `unverifiable`. Nothing re-pulled once
the catalog landed, so the tabs stayed missing and the host ledger stayed stale
until a reconnect. The apply now reports the paths it dropped and target-sync
watches the catalog for them, re-pulling a fresh host snapshot when they become
placeable.

#15484 — exportRemoteWorkspaceSession keys the host projection by worktree path,
which drops the repoId, so two local rows for one remote checkout collapsed and
the last one won outright. An empty duplicate row published an empty tab list
for a workspace with live panes, and the upload is a wholesale replace-session.
Union by tab id instead, matching the `Math.max` its sibling recency map already
applied to the same collision.

#11495 — orphan recovery retired a leaf whenever a `terminal.list` with
`requireFreshPtyLiveness: true` named a different PTY behind a handle than the
snapshot frame's pending row did. That is the host attesting the handle is live
under a replacement PTY, which is what a host relaunch looks like. Rebind
instead of remove. Two tests pinned the removing behaviour and are retargeted
with the reasoning.

* fix(remote): handle a rejected deferred-placement pull and bound its retry chain

The deferred placement retry ran its body as `void (async () => { try {…}
finally {…} })()` with no `catch`. `getSnapshot` is an IPC call that rejects
when the relay drops, and `applySnapshot` can reject with it, so a dropped relay
produced an unhandled rejection in the renderer. Swallow it: the module already
documents that a pull which fails is `unverifiable` and the target is left on
`conflict`.

The retry also re-armed itself through `applyUnsolicitedSnapshot` with no cycle
bound, next to a sibling loop capped at MAX_SNAPSHOT_APPLY_ATTEMPTS = 3. When an
apply reports still-unplaced paths that the catalog nonetheless reports
placeable, `waitForSnapshotWorktreePlacement` returns true immediately and the
arm -> pull -> apply -> arm chain never yields. The added test measures 50 pulls
with no yield before this change.

The bound counts only re-arms where the unplaced set stops shrinking. A chain
that keeps placing rows is converging and is already bounded by that set
emptying, so a raw count would strand a legitimately converging target on
`conflict`; a test pins a five-round convergence that a raw count truncates at
three. Re-arms are also only counted inside a retry's own apply, so a fresh host
snapshot arrival does not spend the budget.
2026-09-02 21:33:38 -07:00
Neil 9e9b80cb37 perf(relay): stop two unbounded growth terms behind the long-session SSH slowdown (#17818)
Two costs grew for the life of an SSH session and never came back down.

1. The relay port scan walked every process in /proc and readlink'd every fd
   even after every listening socket already had an owner. Cost was
   O(host processes x fds) per scan, repeating for the session's life. Exit as
   soon as every inode is attributed.

2. SshPtyModelAdmission kept closed provider generations in a Set<number>.
   Provider generations are a process-global monotonic counter shared by every
   SSH target, so the set gained one entry per relay reconnect forever. After
   500k reconnects main retains ~10,234 KB / 500,000 entries; with this change,
   18 KB / 1 range.

Closed generations now live in SshPtyClosedGenerationRanges, which collapses
contiguous closed runs. Membership stays exact -- a generation below the
high-water mark can still be live on another host, so a high-water
approximation would reject a healthy target's output.

The range container's has()/add() were a linear scan; both are now binary
search. has() is on the per-output-chunk admission path, so a scan would have
traded a bounded Set lookup for one that degrades with fragmentation. This also
speeds up ssh-pty-output-generation-guard.ts, which already uses this container
on main.

Known limitation, deliberately not addressed here: the closed-generation set is
bounded in the healthy case (one range) but unbounded when generations leak,
since each leaked generation leaves a permanent gap. Sublinear is not bounded. A
live-generation set would be bounded by construction and is the better
long-term design; that is a follow-up.
2026-09-02 21:33:34 -07:00
Neil 64dac75d9b fix(ssh): stop respawning panes on client-side-only absence evidence (#17957)
* fix(ssh): stop respawning panes on client-side-only absence evidence

Three respawn gates acted on evidence weaker than host-attested exit.
Per docs/reference/ssh-execution-boundary.md, loss of contact, a failed
reattach, an identity mismatch and absence from a client map are all
`unverifiable`, never `exited`.

Gate 1 (ipc-pty-connect.ts): "belongs to SSH connection" is minted by the
id router from a pure client-side string compare, before any relay is
asked, and still returned `sessionExpired: true` -> fresh PTY + agent
resume. After an SSH target re-adoption the "other" connection is the
same machine, so that puts a second `claude --resume` on the transcript
the surviving PTY still owns. Now returns undefined with no error, which
routes the pane to recoverUnverifiableDirectSshReattach (remount +
reattach, no shell restart) and keeps #7661's no-red-toast outcome.

Gate 3 (ssh-reconnect-pane-retry.ts): `!tabPtyId` read `tab.ptyId`, which
is only the single-pane fallback for legacy attach. It diverges from the
real records deterministically: workspace-terminal-reconnect fills
ptyIdsByTabId from the leaf map but writes tab.ptyId only when a
tab-level id survives, and clearTransientTerminalState nulls tab.ptyId on
every hydrated row. Both leave live leaf PTYs with a null fallback field,
arming a generation bump onto the fresh-spawn path. Now consults
ptyIdsByTabId and the layout leaf map too; a tab with no PTY in any
record still retries.

Gate 2 (recoverTerminalPane): an `expired` lease plus `!pty.connected`
authorized createTerminal. Every writer of `expired` records that the
CLIENT lost its route, not that the shell died. Now also requires the
runtime's own liveness verdict to be neither `live` nor `unverifiable`,
and ssh-relay-session records markPtyLivenessLive at the persistPtyBinding
refusal, which is reached only after pty.attach succeeded. See the report
for why this branch is currently unreachable for SSH panes.

* fix(ssh): let the respawn gate see the relay's own absence answer

Gate 3 refused to respawn a pane whose records still named a PTY, which is
right for a transport drop and wrong for a killed relay: after the relay is
SIGKILLed and comes back, the leaf map still holds `pty2:<dead-epoch>:1` while
the new relay answers that it has no such id. #18017's "replaces the pane only
when the host proves the session is gone" regressed on exactly that.

The gap was not the predicate, it was its inputs. `handlePtyReattachFailure`
already distinguishes the three reattach outcomes and only its not-found branch
publishes anything — a lost link and an identity mismatch send nothing. But it
published `pty:exit { code: -1 }`, and `-1` is the sentinel every reader
resolves to `stop_unverified`, so the one branch holding positive host evidence
of absence arrived looking exactly like loss of contact. The renderer had no
host answer at all, which the gate's own comment conceded.

The exit now carries `livenessVerdict: 'exited'` beside the unchanged `-1`, so
the code keeps meaning "no provable status" for every existing reader while the
verdict rides its own field. A store bridge records those ids in
`hostAttestedAbsentPtyIds` regardless of whether a pane is mounted to hear it —
during reconnect none is — and the gate stops counting a recorded id the host
has disowned. Settled when a PTY answers to that id again, because a redeployed
relay renumbers from pty-1.

This narrows #17963, which pinned the same exit as unverified on the grounds
that not-found cannot separate "verified the pid is dead" from "my session map
never had this id". Everything #17963 protects is untouched: `-1` still fails
isProvenProcessExit, so the tab is not closed, the pane's leaf binding is not
dropped on exit, and markUnverifiedPtyLoss still fires. Only the reconnect
respawn gate reads the new field, and only for an id whose sole channel — the
relay that answered — has disowned it, which no client can reach again under
any verdict. That is the reading ssh-pty-relay-absence-verdict.test.ts already
pins for the spawn path; the reconnect path now agrees with it.

Rejected: parsing the relay's mint epoch out of `pty2:<epoch>:<n>`. It needs the
current epoch on the wire (a capability-negotiated relay change), it has no
answer for legacy `pty-N` ids, and a relay that comes back with zero PTYs gives
the client no epoch to compare against. Rejected: clearing the leaf record
outright, because the remote workspace snapshot re-hydrates those ids after the
clear and the gate would refuse again.

* refactor(ssh): name the relay-disowned signal for disownership, not exit
2026-09-02 21:21:54 -07:00
NeilandNeil 76836b30ea fix(ssh): stop respawning an agent onto a PTY the relay just proved alive (#17951)
* fix(ssh): stop reporting live relay PTYs as expired sessions

A `pty.attach` reply carrying `sourceRecovery: restoreRequired` is the relay
answering for a PTY it just found in its pool and proved alive with
`isProcessAlive`; only the stale output delivery was retired. Main converted
that into `SSH_SESSION_EXPIRED`, which is the token every caller uses to retire
the pane binding and cold-restore the agent, so a transient reconnect started a
second `claude --resume` over a running one's transcript and left the previous
remote PTY detached — one more per reconnect until the host refused to fork.

Retry the attach once (the relay retires the stale delivery as it answers, so
the next attach opens a fresh one with full replay), then fail with a
restore-required verdict that makes no claim about absence. Callers already
route anything short of absence to the unverifiable pane-recovery path.

Also tighten the renderer's expiry verdict, which was a bare substring test: an
identity mismatch names a LIVE PTY owned by another pane and observes nothing
about this one, and main's own gate already refuses to respawn on it.

Refs #11006, #9034

* fix(lint): merge the duplicate pty-connect-limits import

* test(ssh): stop pinning the expiry token on a restoreRequired refusal

The refusal now carries SSH_PTY_SOURCE_RESTORE_REQUIRED, so the ratchet
asserts the discriminating token instead of the one it no longer shares.

---------

Co-authored-by: Neil <neil@example.com>
2026-09-02 21:05:18 -07:00
Neil 946627f2ce fix(runtime): route runtime filesystem commands by resolved execution host (#18325)
`ResolvedRuntimeFileTarget` carried `connectionId?: string` and no host id, so
`undefined` spelled three different answers at once — "runtime: host", "unresolved"
and "genuinely local". Its sole resolver read `store.getRepo(worktree.repoId)?.connectionId`
and never looked at `worktree.hostId`, which outranks every repo row, so one
arbitrarily chosen row decided the execution host for ~30 filesystem dispatches.
This is #18307's defect in the same file family; it was deliberately left out of
that PR rather than doubling an already-36-site diff.

The target now carries `executionHostId: ExecutionHostId` (never null, never
optional), resolved through `resolveWorktreeHostRouting` — the same adapter #18307
added — and dispatched through #18296's `resolveFilesystemRouteForHost`. Dispatch
sites call `requireRuntimeFileProvider`, where `null` means exactly one thing: the
host is `local` and the read happens here.

Four answers that used to collapse into one:

- `ssh:x` with a rival row on `ssh:y` — routes to x. Previously the first row won.
- `local` with a surviving `connectionId` — a row contradicting itself; no SSH
  connection is handed out.
- `runtime:<env>` — throws `ExecutionHostNotDispatchableError`. Its repo row's
  connection names a target in the *server's* namespace; reading it here reaches a
  same-named target on this client.
- rival rows disagreeing with no worktree host — `worktree_execution_host_unresolved`,
  matching the launch and Git paths rather than guessing a row.

Two further reads stop degrading. `assertRuntimeFileMutationExpectation` recomputed
the host from `connectionId`, so a client's host expectation could pass against a
host the workspace never named; it now compares the resolved host. And the
cross-workspace terminal tap coalesced `knownWorkspaceTarget?.connectionId ??
connectionId`, so a sibling workspace resolved as `local` inherited the origin
worktree's SSH target and statted a local path on the remote box; a non-optional
host id replaces rather than coalesces.

An unreachable SSH host still throws `SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE`;
loss of contact is never evidence of locality (docs/reference/ssh-execution-boundary.md).
Quick-open listing and path search keep degrading to empty for an unreachable host —
that is a false negative, not a local answer — and now do so only for a host that
really is remote.

The whole `runtime-file-commands-*` family carries `@ts-nocheck` from a mechanical
class split, so removing the field could not raise the compile errors that made
#18307 safe. `runtime-file-command-target.ts` is deliberately checked, and a ratchet
test stands in for the errors the family cannot produce.

No wire change: `ResolvedRuntimeFileTarget` is main-process internal, and the SSH
watcher-release and grant keys are byte-identical to before.
2026-09-02 20:47:09 -07:00
Brennan BensonandMerge Sim 21210aad34 fix(native-chat): make structured Codex launches race-resistant (#18251)
* fix(native-chat): cancel close-racing structured launches

* fix(native-chat): make structured launches observable and recoverable

* fix(native-chat): reconcile merged session tab publications

* refactor(native-chat): unify host snapshot versioning

* refactor(native-chat): complete launches from host snapshots

* fix(native-chat): replay unknown launches by intent

* fix(native-chat): guard duplicate launches and bound sync recovery

* test(native-chat): type owner fixture

* test(native-chat): type owner fixture

* fix(native-chat): back off structured session resubscribe

* fix(native-chat): fence delayed local session snapshots

* fix(native-chat): retry initial session sync safely

* fix(native-chat): refresh before sync retry

* test(native-chat): cover folder sync cursor cleanup

* fix(native-chat): retry failed structured session subscriptions

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-02 20:23:11 -07:00
Brennan BensonandMerge Sim 40d9927f01 fix(native-chat): show images in Codex structured chat (#18266)
* fix(native-chat): render structured image refs from their runtime owner

* fix(native-chat): keep transcript image keys stable

* fix(native-chat): memoize the image runtime owner

* fix(native-chat): keep image preview observation scoped

* fix(native-chat): resolve runtime-only image owners

* fix(native-chat): retain image preview cache leases

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-02 19:52:19 -07:00
Neil d5750648c2 fix(runtime): route runtime Git by resolved execution host, not repo connectionId (#18307)
`RuntimeGitTarget` carried `connectionId?: string` and no host id, so `undefined`
spelled three different answers at once — "runtime: host", "unresolved", and
"genuinely local". Its sole resolver read `store.getRepo(worktree.repoId)?.connectionId`
and never looked at `worktree.hostId`, which outranks every repo row, so one
arbitrarily chosen row decided the execution host for 36 downstream dispatches.

The target now carries `executionHostId: ExecutionHostId` (never null, never
optional), resolved through the shared rule that landed with #17909/#17919 and
dispatched through the host-keyed routes from #18296. Dispatch sites call
`requireRuntimeGitProvider`, where `null` means exactly one thing: the host is
`local` and the command runs here as free functions.

Four answers that used to collapse into one:

- `ssh:x` with a rival row on `ssh:y` — routes to x. Previously the first row won,
  which is the reproduced cross-host leak.
- `local` with a surviving `connectionId` — a row contradicting itself; no SSH
  connection is handed out.
- `runtime:<env>` — throws `ExecutionHostNotDispatchableError`. Its repo row's
  connection names a target in the *server's* namespace; dialling it here reaches a
  same-named target on this client.
- rival rows disagreeing with no worktree host — `worktree_execution_host_unresolved`,
  matching the launch path rather than guessing a row.

An unreachable SSH host still throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE`; loss of
contact is never evidence of locality (docs/reference/ssh-execution-boundary.md).

`resolveWorktreeLaunchHost` keeps its exact signature and now delegates to
`resolveWorktreeHostRouting`, the same resolution answering "which host is this on"
rather than "what may this client dial" — the git target needs the first question
because `local` and `runtime:` are two different non-SSH answers.

No wire change: `RuntimeGitTarget` is main-process internal, and the SSH and local
model-discovery host keys are byte-identical to before.

`RuntimeFileTarget` has the same defect in ~30 filesystem dispatches and is
deliberately left for a follow-up.
2026-09-02 19:26:07 -07:00
Neil 9cda5a9dc0 fix(worktrees): stop a resolved-worktree snapshot answering for repos it never saw (#18295)
* fix(worktrees): stop a resolved-worktree snapshot answering for repos it never saw

`listResolvedWorktrees` caches one fleet-wide snapshot for
RESOLVED_WORKTREE_CACHE_TTL_MS (1s) and reuses it on time alone. Nothing
invalidates it when a repo is registered, so for up to a second after a repo
row lands, every caller reads a snapshot computed before that repo existed --
and reads the gap as a verdict.

The visible failure is the SSH skill install. `resolveSkillSshTarget` resolves
a workspace-scope destination through that snapshot, so installing into a
worktree on a host connected moments earlier threw
`skill-install-workspace-not-found`: the client asserting a remote workspace is
absent on the strength of client-side bookkeeping that had never looked at the
host. That is the shape `docs/reference/ssh-execution-boundary.md` rules out --
absence from a client-side set is not evidence about the execution host. It
made `tests/e2e/ssh-skill-installation.spec.ts:108` fail 3 runs in 4 locally
and deterministically in the Docker SSH lane, where connect-then-install lands
inside the one-second window every time.

The snapshot now carries the repo-registration revision it was computed under
and is only reused while that revision still holds. The counter is the one
`bumpLocalWorktreeScanGeneration` already advances on every repo add, removal
and update, so the check is O(1) and cannot drift from the mutation sites.

* fix(worktrees): key the snapshot on repo mutations only, not on generation reads

Two things the headless-reattach lane surfaced.

The revision I keyed the snapshot on was `generationSequence`, which
`getLocalWorktreeScanGeneration` also advances when it mints a key for a repo
id nothing has scanned yet. That is a read, not a mutation, so a read path
could discard a snapshot that was still perfectly valid -- the mirror image of
the staleness this fixes, and a way to make a lookup fail that would otherwise
have succeeded. The counter now advances only where the scan generation is
actually bumped: repo add, removal, update, and scan-cache invalidation.

Separately, `pty-restore-record-seeding.test.ts` primed the cache by writing
its private `resolved` field with a literal spelling out `worktrees`,
`platformByRepoId` and `expiresAt`. That literal is a second copy of the
cache's freshness contract, so adding a field to the real entry left the fake
one failing the check: the primed snapshot was rejected, resolution fell
through to a real scan, and the headless fixture -- which has no git -- got
`selector_not_found`. It now primes through `getSnapshot` so the cache stamps
its own entry and the two cannot drift again.

The revision never moved during that test (0 before and after), so nothing was
being invalidated; the fake entry simply never satisfied the contract.
2026-09-02 18:13:08 -07:00
Neil 953df47fc4 feat(providers): dispatch git and filesystem providers by execution host (#18296)
`const c = repo.connectionId; c ? sshProvider(c) : local()` overloads `null`
to mean both "resolved: local" and "could not resolve", so every path that
cannot determine the host silently runs remote work on the client (#11163).
It also cannot express a `runtime:` host at all.

Add a host-keyed dispatch whose input is an `ExecutionHostId` — never null —
with `local`, `ssh` and `runtime` as three symmetric entries, and which throws
on an id that names no host instead of degrading to this machine. `ssh` carries
`provider: null` for "remote, currently unreachable", which is now a different
answer from "local" rather than the same one.

`runtime:` is a distinct entry rather than a provider because main does not
execute runtime hosts at all: they are forwarded over the environment transport,
and a runtime row's `connectionId` names a target in the server's namespace.
Dialing it from this client's SSH table would trade a silent-local bug for a
silent-wrong-host one.

First migrations, both to rows resolved via `getRepoExecutionHostId`:
- repo-worktrees: an `executionHostId: 'ssh:*'`-only row no longer lists,
  root-matches, or strict-lists against a same-named local path.
- workspace-space-repo-scan: same for the size scan, and `isRemote` no longer
  contradicts the `executionHostId` emitted beside it.
2026-09-02 18:04:48 -07:00
github-actions[bot] e827ce2ccb Update README downloads badge 2026-09-03 01:01:23 +00:00
Neil d084a2a36a fix(ssh): decide remote-vs-local from the resolved execution host, not a raw field (#18294)
`repoIsRemote` read `repo.connectionId` directly. That is one of four spellings
of host ownership, so the predicate was wrong in both directions: a row carrying
only `executionHostId: 'ssh:<target>'` read as local and got the Linux-only
`orca-ide` rename it cannot resolve through the relay shim, while a row that
declares itself `local` with a stale `connectionId` read as remote and lost the
rename it needs on a Linux desktop.

The predicate now resolves the host first and asks "does an SSH target hold this
row's files" via `getRepoSshConnectionId`. That keeps a `runtime:` host's nested
SSH target remote (that machine reaches the files through its own relay shim)
while a runtime with no nested target - a full Orca install - stays local, as do
WSL and local.

Its call sites did not all want that question:

- The four launch-scope sites in main already hold the resolved PTY route on
  `TerminalWorkspaceLaunchScope.connectionId`. `scope.repo` is documented display
  metadata and can be a row from a different host than the worktree names, so
  they now read the route they will actually spawn on. A launch shape that
  disagrees with its own route is the bug, not a second predicate.
- `launchAgentInNewTab` picked its repo row with a host-blind
  `store.repos.find`, so a worktree that names its own host could be shaped by
  another host's row. It now resolves through `getConnectionIdFromState`, the
  same rule the file already used for transcript readability.
- `resolveAgentBackgroundLaunchHost` derived the route, the trust write and the
  launch shape from three reads of the raw field; one resolution now feeds all
  three.

Also converts the raw `repo.connectionId` agent-detection probe eight lines above
`buildWorktreeStartupForDraft`'s launch shape, which #17919 deferred precisely
because converting it alone would have left that file internally inconsistent.

Tests cover two distinct SSH hosts (a single-host fixture passes even when the
answer comes off the wrong row, which is how the `ssh:m4air` -> openclaw leak
survived review) and a `runtime:` host carrying a nested SSH target.
2026-09-02 17:46:01 -07:00
Jinjing 36e139ed19 docs: update localized Android APK links to 0.0.47
Update localized README links to the latest verified mobile Android release.
2026-09-02 17:03:36 -07:00
Neil 7c94d12190 fix(ssh): route four host-blind seams through the resolved execution host (#17919)
* fix(host-routing): resolve the execution host before reading a connection

Three issues in one defect class: a resolver reads one spelling of one
arbitrarily chosen row instead of resolving the worktree's execution host,
so something local answers a question about a remote.

returned that row's connectionId. With duplicate repo rows for one repo id
it could pair a runtime owner with a client-owned SSH connection. It now
resolves through the same ambiguity-aware index getRuntimeEnvironmentIdForWorktree
uses, prefers the repo row for the host the worktree names, and derives the
connection from the resolved host. Conflicting rows return `undefined`
(this module's documented "cannot determine the host"), never `null`.

`store.getRepo(worktree.repoId)?.connectionId ?? null`. `getRepo` is
host-blind and the same repo id can exist on local, SSH and runtime hosts,
so a remote worktree could spawn its PTY on the client with the remote cwd.
resolveWorktreeLaunchHost picks the row for the worktree's host and reads
the connection off that host; conflicting rows are unresolved, not local.

session-partition owner maps that contradict each other. Both now compute
through one shared function whose argument records the divergence. No
behaviour change on either side: converging needs a read-both migration,
since both partitions hold real data written by shipping builds.

* fix(host-routing): keep nested SSH connections resolvable under a runtime host

getRepoSshConnectionId read only the resolved execution host, so a repo row
owned by a runtime that reaches a nested SSH target (connectionId: ssh-*,
executionHostId: runtime:*) resolved to no connection — answering 'local' for
a remote worktree, the same defect #17909 fixed in the other direction.

* fix(host-routing): resolve both sides of the execution host through one rule

The renderer resolver leaked between two different SSH hosts: a worktree on
`ssh:m4air` whose only indexed repo row belonged to `openclaw` answered
'openclaw', because the host-scoped lookup missing fell through to an id-only
one. Main's resolver, in the same change, answered 'm4air' — two resolvers, one
right and one wrong, on identical input.

Both sides now adapt one shared rule (`worktree-execution-host-resolution.ts`):
the worktree's own host outranks every repo row, and a row on a different host
is never evidence about this one. The renderer's WeakMap index becomes the
memoizing adapter it always was; `resolveWorktreeLaunchHost` becomes main's
mapping of unresolved onto its throw.

Settles the rule the change previously answered two ways.
`getRepoSshConnectionId` and `getSshTargetIdForExecutionHost` disagreed for a
runtime host carrying a nested `connectionId`; they now compose, so the
execution host is the single authority. On a `runtime:*` row that field is a
paired HUB's private SSH target, spread through by `repoWithFetchedOwner` and
unaddressable from this client — the project-first successor of the row nulls it
for exactly that reason. That also fixes the `kind !== 'ssh'` fallback, which
fired for `local`: a row declaring itself local handed out an SSH connection.

* fix(ssh): resolve the execution host in the worktree scan and managed create

The worktree scan and createManagedWorktree both picked remote-vs-local from
repo.connectionId, so a row stamped only executionHostId: 'ssh:*' was scanned
and created on the client against a remote path. The folder branch returns
before the check, so its agent-trust write landed locally too.

Refs #11163

* fix(ssh): stop over-rejecting and refusing SSH hosts the process owns

runtimeRepoMatchesExecutionHost rejected an unstamped SSH repo against its own
ssh:<connectionId>, so repo-add/clone dedupe could register a second row for a
path the host already owns. assertHostIsSupported made the CLI/runtime RPC
refuse --host ssh:* while the same process's IPC handler routed it correctly;
setupExistingFolder now shares that registration. Clone still refuses, because
nothing in this process clones onto an SSH host.

Refs #11163

* test(ssh): retarget the SSH host-setup guard spec at the substitution it prevents

setupProjectExistingFolder now registers the remote path through the same
addRemoteRepoFromPath the desktop IPC uses, so it fails on the host's terms
(connection not registered) rather than a categorical refusal. The local
clone/probe side effects it exists to catch are still asserted absent.

Refs #11163

* fix(cli): require an absolute path when setting a project up on an SSH host

Routing --host ssh:* to the remote registration made relative paths newly
reachable there, and they were resolved against the client cwd — registering a
path that names the wrong machine.

Refs #11163

* fix(repos): read the SSH registry directly so the runtime stays Node-bootable

Routing runtime project setup through addRemoteRepoFromPath dragged ipc/ssh --
and its 25-module electron graph -- into the runtime bundle. ssh-target-registry
already exists for exactly this; ipc/ssh only re-exports it.

* fix(ssh): close the agent-launch and session-export host-blind twins

Three sites left on the legacy spelling, all the same shape as the ones this
branch already fixed:

- `launchAgentTerminal` did `getRepo(worktree.repoId)` then wrote agent trust
  with that row's `connectionId`. Host-blind, so a repo id carried by two SSH
  hosts wrote a remote path into the *client's* Codex/Cursor/Copilot config and
  the agent on the host never saw the trust. Every sibling call site already
  passes the resolved `workspace.connectionId`; this was the last that did not.
- `targetForWorktree` (workspace-session export) fell back to the same
  host-blind read, so a session could be published to a machine that never
  owned the worktree. Unresolvable ownership now exports to nobody.
- `addRemoteRepoFromPath` minted `connectionId`-only rows while being the
  routing path this branch adds, so it kept creating rows in exactly the
  spelling the branch works around. It now stamps
  `toSshExecutionHostId(connectionId)` at creation; `reassignSshTargetId`
  already migrates both spellings, so target rename stays correct.

Tests cover two *different* SSH hosts throughout — the case none of the earlier
duplicate-row tests had, all of which were local-vs-ssh or runtime-vs-ssh.
2026-09-02 16:59:29 -07:00
Neil c61ca56a9b fix(ssh): resolve the worktree's execution host instead of guessing from one repo row (#17909)
* fix(host-routing): resolve the execution host before reading a connection

Three issues in one defect class: a resolver reads one spelling of one
arbitrarily chosen row instead of resolving the worktree's execution host,
so something local answers a question about a remote.

returned that row's connectionId. With duplicate repo rows for one repo id
it could pair a runtime owner with a client-owned SSH connection. It now
resolves through the same ambiguity-aware index getRuntimeEnvironmentIdForWorktree
uses, prefers the repo row for the host the worktree names, and derives the
connection from the resolved host. Conflicting rows return `undefined`
(this module's documented "cannot determine the host"), never `null`.

`store.getRepo(worktree.repoId)?.connectionId ?? null`. `getRepo` is
host-blind and the same repo id can exist on local, SSH and runtime hosts,
so a remote worktree could spawn its PTY on the client with the remote cwd.
resolveWorktreeLaunchHost picks the row for the worktree's host and reads
the connection off that host; conflicting rows are unresolved, not local.

session-partition owner maps that contradict each other. Both now compute
through one shared function whose argument records the divergence. No
behaviour change on either side: converging needs a read-both migration,
since both partitions hold real data written by shipping builds.

* fix(host-routing): keep nested SSH connections resolvable under a runtime host

getRepoSshConnectionId read only the resolved execution host, so a repo row
owned by a runtime that reaches a nested SSH target (connectionId: ssh-*,
executionHostId: runtime:*) resolved to no connection — answering 'local' for
a remote worktree, the same defect #17909 fixed in the other direction.

* fix(host-routing): resolve both sides of the execution host through one rule

The renderer resolver leaked between two different SSH hosts: a worktree on
`ssh:m4air` whose only indexed repo row belonged to `openclaw` answered
'openclaw', because the host-scoped lookup missing fell through to an id-only
one. Main's resolver, in the same change, answered 'm4air' — two resolvers, one
right and one wrong, on identical input.

Both sides now adapt one shared rule (`worktree-execution-host-resolution.ts`):
the worktree's own host outranks every repo row, and a row on a different host
is never evidence about this one. The renderer's WeakMap index becomes the
memoizing adapter it always was; `resolveWorktreeLaunchHost` becomes main's
mapping of unresolved onto its throw.

Settles the rule the change previously answered two ways.
`getRepoSshConnectionId` and `getSshTargetIdForExecutionHost` disagreed for a
runtime host carrying a nested `connectionId`; they now compose, so the
execution host is the single authority. On a `runtime:*` row that field is a
paired HUB's private SSH target, spread through by `repoWithFetchedOwner` and
unaddressable from this client — the project-first successor of the row nulls it
for exactly that reason. That also fixes the `kind !== 'ssh'` fallback, which
fired for `local`: a row declaring itself local handed out an SSH connection.
2026-09-02 16:41:13 -07:00
Neil 4b2d9b5aac fix(path): stop seeded user bin dirs from outranking the inherited PATH (#18265)
`patchPackagedProcessPath` prepends every seeded directory, so `~/bin` and
`~/.local/bin` land ahead of the PATH a GUI-launched Electron inherited.
That does more than make a tool findable, which is what seeding is for --
it re-ranks binaries the user already has, and those two directories are
user-writable and can hold a wrapper for any system tool.

On the #18234 reporter's box `~/.local/bin/gh` wraps `mise x gh -- gh`.
Seeded ahead of /usr/bin we ran the wrapper where their own shell ran the
real binary, and the wrapper's inner bare `gh` resolved back to itself.
Measured in an Ubuntu 24.04 container: with their shell's ordering the
chain exits in 22ms; with ours it never terminates and creates ~1,500
processes/second.

Seed order now follows the rule the WSL twin already documents in
posix-version-manager-bin-dirs.ts -- append, never prepend, because a
login PATH that did resolve is authoritative. Version-manager shim dirs
keep leading, since an nvm/mise/asdf user's runtime must still beat a
system install; the generic user bin dirs move behind the inherited PATH.
`getVersionManagerBinPaths` carries `~/bin` and `~/.local/bin` too (bun
and pnpm install there), so they are filtered out of the leading group by
name rather than by which list produced them.
2026-09-02 16:34:44 -07:00
Jinjing b8b7a6be9d fix(activity): persist the agents unread filter and grouping (#18255)
* fix(activity): persist the agents unread filter and grouping

The Agents view's "Show unread threads only" toggle and Group-by select were
plain component state in the sidebar and the Activity page, so both reset on
every mount — including app restart — while their neighbours in the same
toolbar (compact rows, show child agents) survived via the persisted UI store.

Promote both to `agentsReadFilter` / `agentsGroupBy` persisted UI preferences,
wired through the same seams as `agentsCompactMode`: shared type, default,
strict client RPC schema, pairing-local field census, web read pin, store
contract/actions, and hydration normalizers that reject unknown values. Both
consumers now read the store, so the sidebar and the Activity page share one
filter the way they already share compact mode.

* refactor: centralize thread filter value domains

Establish filter and groupby value domains as the single source of truth, with types derived from them to prevent drift between valid values and their normalizers. Extract common validation logic into a shared isMember helper to keep the two normalization functions in sync.

* refactor: centralize thread filter value domains

Consolidate filter value definitions in agents-view-thread-filters
and use them in Zod schema validation to ensure consistent,
persistent serialization of filter state.
2026-09-02 16:04:11 -07:00
Neil 6cd477a2f1 test(e2e): un-rot the SSH freeze repro and probe two failure modes nothing covered (#17940)
Test-only. No production code.

## The freeze repro was rotted in three ways, not one

#16764 tracks four stale call sites. There were three separate problems:

1. **Stale call sites** — `execInTerminal` gained a `ptyId` and
   `splitActiveTerminalPane` gained a direction. (`startDockerSshRelayTarget`'s
   missing `testInfo` was the third; #18257 has since landed it on main.)
2. **It connected before session restore settled**, so the seeded tab never bound
   to a remote PTY and the terminal sat on "Connecting…" forever.
3. **It could never have passed, even once.** It waited for a one-shot `READY:`
   line through a 4000-char terminal window while its own 2 KB-every-8 ms flood
   buries that line within ~16 ms. Readiness is now keyed on the repeating `BG:`
   flood marker, which is strictly stronger — it proves the pane is streaming
   rather than merely started.

It now runs end to end and prints a measurement instead of dying on a call site:

```
[freeze-repro R2] hiddenFloodMaxLagMs 2.1  bulkOpenMaxLagMs 41.5
                  interactionProbeMs 53.6  softFreeze false  hardFreeze false
```

**It is still not CI-gateable, and the exclusion comment now says so.** The same
spec on the same commit measured `bulkOpen 2575.6ms / interaction 3464.2ms` on a
GitHub ubuntu runner against a 2500 ms soft budget — a ~60x spread on the number
the budget reads, with the relay still streaming. That is the budget failing, not
the product. The earlier draft of this comment claimed "repaired and passing",
which was true only of the host it was measured on; gating this needs a
host-relative oracle, not a bigger constant.

## New: a half-open link is judged, not wedged

The fixture image has no `iptables` and the container has no `NET_ADMIN`, so
`docker pause` is used instead — a harder case, because the container's TCP stack
keeps ACKing: no FIN, no RST, and the socket looks perfectly healthy. Only an
application-level probe can detect it.

```
[half-open] {"verdict":"reconnecting","verdictMs":25135,"budgetMs":90000}
```

Nothing in the suite covered the failure mode behind the "SSH hangs until I
restart Orca" reports.

## New: resource accumulation measured on the remote host

6 terminals, then 5 reconnect cycles, counted on the container itself:

```
open:       pts 1->6 (exactly 1/terminal), relay fds 25->30 (exactly 1/terminal)
reconnect:  pts flat at 6, relay procs flat at 1, node procs flat at 3
```

`leakedMasterFdCount` is now **asserted**, not merely recorded. It counts PTY
master fds held by non-relay processes: without `FD_CLOEXEC` a master is inherited
by every later child, so terminal k adds k of them — the triangular signature
measured as 15 across 5 terminals before the fix. #17914 patched the app and
daemon and #17920 shipped the same patch to the relay host, and both are now on
main, so the correct value is 0 and the probe holds it there:

```
baseline    leakedMasterFdCount 0
6 terminals leakedMasterFdCount 0    (holders: only relay.js, n=6)
reconnects  leakedMasterFdCount 0 across all 5 cycles
```

Any growth here means the relay's node-pty rebuild did not take on that host,
which is exactly what a remote-host probe exists to catch — and it is the half of
#17914's claim that no unit test can reach.

## Routing

Both new probes are claimed by `run-ssh-docker-e2e.mjs` (a Docker-gated spec no
runner names self-skips everywhere and still reports green) **and** by the
`ssh-terminal-source` route in `pr-e2e-source-routing.mjs`, so they run when the
relay and SSH code they guard changes rather than only on a scheduled lane.
2026-09-02 15:58:42 -07:00
Neil 510305e574 fix(relay): signal capacity loss instead of dropping, hanging, or truncating (#17870)
Three failures with one shape: a payload past a fixed capacity was met with
silence, with a wait that never ends, or with a prefix presented as a whole.

**The workspace snapshot was silently dropped.** `workspace.changed` carries the
tab/session list, and a snapshot past the producer frame capacity (12288 B on a
Node <=21 remote) was dropped with only a relay stderr line, so the client kept a
stale list forever. The relay now publishes per client and, for a client whose
sink refused the frame, sends a compact `workspace.stale` marker on the control
lane; the client re-reads through `workspace.get`, whose lane is budgeted in
megabytes rather than in one producer frame. A new JSON-RPC notification rather
than a new field on `workspace.changed`: `normalizeSnapshot(undefined, ns)` yields
revision 0 and an empty session, so a Rule-1 field would make an old client
replace its tab list with nothing — worse than the drop. An old client ignores the
unknown method and is exactly where it is today. The marker retention/retry
machinery is extracted from the `fs.changed` overflow path and shared by both.

**The Windows upload hung, and the fix for it could truncate.** `#16432` was
attributed to `[Console]::In.ReadToEnd()` materializing the base64 bundle. That is
not what the reporter measured: he also measured
`new IO.StreamReader([Console]::OpenStandardInput())` — an incremental reader —
hanging at 1 MB. The limit is in the stdin the host hands PowerShell over a
non-pty ssh exec, not in the string the script builds.

- `uploadFileViaSystemSsh` — the user file-import path — was piping a whole file
  into one Windows stdin, unchunked and untimed. That is the path large files
  take; it now chunks into 32 KB writes and bounds each wait.
- The Windows directory upload reuses that single-file path rather than repeating
  a weaker copy of chunk-read + write-buffer; the `ino`/`dev` TOCTOU verification
  comes with it.
- A Windows write needing more than one exec lands on a `.orca-partial` staging
  path and is published by rename, so a failed chunk cannot leave a truncated
  artifact under the real name. `exclusive` is enforced once at the rename, not on
  the first chunk, where a retry met its own leftovers.
- The mkdir batch reads stdin through the stream reader the reporter measured
  surviving 50 KB, not `[Console]::In`, which he measured wedging at that size.
- `waitForChannelClose` takes an optional bound. A wedged PowerShell stays alive
  at idle CPU and never closes, so without one the promise is simply never
  settled and the caller waits forever with no error to show.

**Quick Open showed a prefix as the whole workspace.** The mechanism "a full page
means there is more" only works if the caller named the cap, and the failing UI
named none — it hardcoded `truncated: false`. Quick Open now names
`QUICK_OPEN_LISTING_MAX_RESULTS` on both the Electron IPC hop and the runtime-RPC
hop (the field #17954 added to `files.listAll`), and reads a full page as
truncation. The local hop honours the cap too, which it previously ignored.

Rebase note on `fs.listFiles`: an earlier revision of this work also clamped the
host unconditionally, and #17934 escalated an uncapped request to an explicit
error. #17954 has since landed and made an oversized reply streamable, which
removes the premise — the host no longer has to choose between a prefix and a
refusal, so it returns the whole listing when no limit is named and only clamps a
limit it was given. Keeping either would have regressed #17954 and hard-failed
three in-tree callers that deliberately pass no options
(`runtime-file-commands-search-runtime-files.ts:81`,
`filesystem-read-handlers.ts:125`, `runtime-file-commands-constructor.ts:41`).
2026-09-02 15:42:08 -07:00