Commit Graph
220 Commits
Author SHA1 Message Date
Neil f975035809 refactor(ipc): split preflight and SSH registry out of the ipcMain modules (#15927)
* refactor(preflight): split agent detection out of the ipcMain registration

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

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

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

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

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

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

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

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

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

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

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

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

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

Ratchet baseline 27 → 25.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(runtime): inject the browser commands factory

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

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

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

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

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

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

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

Ratchet 8 → 5.

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

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

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

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

Ratchet 5 → 4.

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

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

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

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

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

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

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

Verified with CI's exact invocation: exit 0.

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

Ratchet 4 → 2.

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

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

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

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

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

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

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

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

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

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

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

* fix(app-environment): read hasAppEnvironment through the realm slot
2026-08-22 21:34:39 -07:00
Neil b7e79b7ca6 fix(windows): one chokepoint for every child process (#15746)
* feat(process): add the Windows-correct child-process chokepoint

Six decisions have to be made every time Orca starts a child process --
console visibility, argument quoting, .cmd interpretation, binary
resolution, timeout policy, and how the tree is later terminated. POSIX
forgives all six. Windows punishes each differently, and made per-call
site across 172 files they were right in some and wrong in others.

runProcess/spawnProcess make them once:
- windowsHide unconditionally, shell:false unconditionally (shell:true
  concatenates argv unescaped and silently disables windowsHide)
- .cmd/.bat routed through cmd.exe /d /v:off /s /c with a verbatim line,
  because Node refuses to spawn them otherwise (EINVAL)

The encoding was derived by measurement on Windows 11, not from the
docs. An embedded quote is written "" rather than \" so cmd's naive
quote count stays even -- with \" the parity flips and every later &
| < > on the line stops being data. Measured before the fix, argv
["a b", 'c"d', "e%F%g", "h&i", "j^k"] arrived as
["a b", 'c"d', "e^%F^%g", "h"]: the & truncated the argument and
ran its remainder as a command. Each % is broken out of the quoted run
as "^%" because %VAR% expands even inside quotes.

The import-boundary test is a ratchet seeded at today's 172 files; it
only shrinks.

* fix(process): route the console-flashing spawn sites through the chokepoint

The ssh -G config probe fires on every connect and reconnect, and ssh.exe
is console-subsystem, so a GUI-subsystem parent gets a fresh visible
conhost that takes foreground -- keystrokes typed into an Orca terminal
at that moment go into the black box (#10488, #14543). Same for the
ProxyJump tunnel, the ProxyCommand cmd.exe wrapper, the font enumeration
and the DPAPI cookie decrypt.

Also stops spawning powershell by bare name: PATH under Electron is not
the user's, so where policy has pruned the System32 entry the spawn fails
and the font picker silently reports five hardcoded families rather than
an error (#11771).

Deletes system-fonts' 40-line bespoke execFileText -- timeout, output cap
and kill are the chokepoint's job now. Adds runProcessSync so the sync
callers have a compliant path; without one the ratchet could never
reach zero.

The three suites that mocked child_process directly now mock runProcess,
which is the point: how a process gets started is no longer each
module's business. Ratchet 173 -> 170.

* fix(process): do not report a deliberately killed child as timed out

runProcessSync inferred a timeout from signal === 'SIGTERM'. Measured:
a real timeout sets error.code ETIMEDOUT and kills with SIGTERM, but so
does anything else that terminates the child -- and those cases set no
error at all. Reading the signal alone reports a process someone stopped
on purpose as having timed out, which callers retry.

* refactor(process): hold the ratchet as data and migrate the pwsh probes

The allowlist and the adversarial argument corpus are read only by tests,
so they were production modules in name only; they move to __fixtures__.

pwsh.ts carried isTimeoutError() purely to reconcile two spellings of the
same event -- execFileSync reports a timeout as ETIMEDOUT, the execFile
callback as a SIGTERM kill with no code. runProcess reports one timedOut
flag, so the helper and the reasoning behind it both go.

Its sync probe also spawned without windowsHide, which flashes a console
and steals foreground on every cold cache read.

* refactor(process): migrate five more spawn sites onto the chokepoint

Each one deletes a hand-rolled promise/timeout/kill wrapper and stops
re-deciding console visibility for itself. Ratchet 170 -> 164.

Two things this surfaced, both kept:

runProcess now accepts string chunks as well as buffers. A stream someone
called setEncoding on emits strings, and concatenating those as buffers
throws inside a data handler -- where the rejection has nowhere to go and
the caller simply hangs rather than failing.

ProcessSpec keeps its AbortSignal. I had removed it as unused; the macOS
PAM preflight passes one through from its own caller.

ipc/app.ts is deliberately NOT migrated. Its probe spawns a three-stage
 pipeline detached so a timeout can reap the group with one
negative-pid SIGKILL; runProcess kills only the root, which would orphan
the plutil stages. Migrating it needs the chokepoint to own POSIX
process-group termination first -- the same guarantee job objects give on
Windows. Reverted and left on the ratchet.

* test(process): do not assert a POSIX signal on Windows

Windows has no signals, so the same deliberate kill reports an exit code
there and a signal on POSIX. What has to hold on both is that neither
shape reads as a timeout. Caught by running the suite on Windows.

(cherry picked from commit 0a6e9902a22a369a0e85e113ea8d87b726f82e1f)

* fix(process): settle a timed-out run even when the child ignores the kill

close only fires once the child is actually gone, so a child that traps
SIGTERM never emits it and the promise outlives its own deadline
forever. That is the same wedge shape just fixed for the process table,
and it is worse here: pwsh.ts and the snapshot reader both cache an
in-flight probe, so one unkillable child hands every later caller the
same dead promise.

After the deadline it now escalates to SIGKILL and settles regardless,
reporting timedOut with whatever output arrived.

(cherry picked from commit 78ac169197c4e6faee1b9310a7186029cc11acbc)

* fix(process): escalate an aborted child too, not just a timed-out one

The grace escalation I added covered the timeout path and left abort on
the old one, so an aborted caller with an unkillable child still waited
forever -- the same defect, one path over. The macOS PAM preflight is a
real caller that passes an AbortSignal.

Both paths now share one stop-and-settle, and the result reports
timedOut honestly: false when the caller aborted.

(cherry picked from commit 7e9523a9e31172bb8183661b56f04c3ab6a03d0d)

* fix(windows): stop percent escaping from forging an escaped quote

escapePercentForCmd ran as a post-pass over the quoted string, so it
inserted a quote wherever a percent was -- including straight after a
backslash. CommandLineToArgvW reads backslash-quote as an escaped quote,
so C:\Users\%USERNAME%\x arrived corrupted. That is about as common as
Windows paths get, and my 20-case corpus had no backslash-before-percent
entry to catch it.

Percent handling is now part of the quoting loop, where the backslash
run is known and can be doubled before the inserted quote. Two corpus
cases cover the shape.

The program path gets the same treatment. It was quoted but not
percent-escaped, so a launcher under C:\Users\%USERNAME%\ had its own
path expanded on the cmd hop.

quoteWindowsArgument no longer takes a boolean. Passing it to
values.map() handed map's index in as the flag -- which is how the first
version of this fix was written, and the corpus test caught it.

Separately: an AbortSignal that was already aborted never fires the
event, so runProcess ran the child to its full timeout for a caller who
had already given up.

(cherry picked from commit f7e2e56b1ee1f27ab6d1035dde4501b38f95b374)
2026-08-21 21:05:24 -07:00
Brennan Benson 3fca1d1648 fix(linear): unbound list-issues by default, surface truncation, bind cursor workspace (#15824)
Fixes STA-5076.

list-issues capped at 50 by default and hard-clamped at 250, with hasMore buried
under result.meta and no stderr warning for --json, so a page that stopped early
read as a complete answer. Omitting --limit now walks Linear's pages until they
run out (meta.limit is null), and --limit <n> is the only cap, paging past
Linear's 250-per-request maximum to reach it. result.truncated sits next to
result.issues and is set only when a cap actually held results back; human output
prints "truncated: showing N".

The read still has to fit the CLI's 60s RPC budget, so a 20s wall-clock deadline
and a 200-page ceiling stop the walk early and report truncated with a
continuation cursor rather than failing the command.

Also:
- issued --cursor values bind the resolved workspace, so call -> nextCursor ->
  call works without --workspace; raw Linear cursors still need one and now carry
  nextSteps
- issued cursors whose payload smuggles back `all` or an empty workspace are
  rejected at decode, since either would widen the read past the bound workspace
- JSON issue rows carry priorityLabel (none/urgent/high/medium/low), matching
  orca linear priority set
- truncated and priorityLabel are optional on the wire, so a host that predates
  either is not read as "complete"; readers fall back to meta.hasMore
- the truncation line prints the rows actually rendered, so a remote result with
  no meta.returned cannot print "showing undefined"
2026-08-21 14:28:55 -07:00
OrcaWin 471bc9d8ce Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529) 2026-08-20 00:16:04 -07:00
Neil a61b39a9a6 fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792) (#15376)
* fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792)

Two independent frame-of-reference bugs, both from code describing one machine
while labelled as another.

#15366 — projectHostSetup.* persisted the caller's host id verbatim. Those
`runtime:<environment-id>` ids are minted by the calling client's own pairing
store, so they name a machine only relative to that client. A client sending
one is addressing this runtime, and runtimes do not proxy these calls onward,
so the host it names is us. Storing the client's spelling made one machine look
like a different host to every other client, hid its rows from them, and
defeated the (projectId, hostId) duplicate check — two laptops paired to one
server each created their own setup for the same checkout. Re-spell it as
`local` at the RPC boundary. Rows written earlier keep their old stamp; readers
already project `local` back to `runtime:<their-id>`, so the client-visible
model is unchanged and no ids are rewritten.

STA-4792 defect 4 — `status --environment <name>` hardcoded app.running:false
to mean "no desktop on THIS machine" while every other field in the same object
described the target, including a desktopWindowStatus echoed straight from it.
The result contradicted itself and read as "that run was headless" when the
remote GUI was up. `app` now describes the target, keyed off the one window
status that requires a live renderer, and the result names its own subject so
the frame can't be misread again. The remote pid is not knowable, so it stays
null.

STA-4792 defect 2 gets a regression test rather than a fix: routing already
made the client remote, which is what stops a Windows destination being joined
to the local cwd. The test pins the exact reported invocation.

* fix(status): share the remote app projection with the SSH host passthrough, and name the version gap on project host setup

Two review follow-ups.

The SSH host passthrough answered `app.running: true` unconditionally for the
Orca host a caller reached over SSH, claiming a desktop app even for a headless
`serve`. That is the same defect as the paired-server path, one transport over,
so the projection moved to shared and both now answer the question the same way.

`--host runtime:<id>` routes project commands to a paired server, which means a
client can reach a server that predates project host setup without meaning to.
That answered a raw `method_not_found`, which reads as an Orca bug rather than a
version gap; the CLI now names it the way the desktop already does.

Reverted a third change: making the persistence duplicate check treat `local`
and `runtime:*` as one machine. That assumption holds at the RPC boundary, where
a `runtime:` host means the runtime being addressed, but not in the store, which
also records independent provisioning metadata for machines that are not itself.
An existing test covers exactly that, and it was right. The duplicate
convergence therefore stays bounded to rows written after the normalization.
2026-08-19 17:12:17 -07:00
Neil 3c676ed13d fix(ssh): validate hashed known_hosts fields through one strict base64 decoder (STA-4717) (#15345) 2026-08-18 15:26:48 -07:00
Neil 24e662adc1 feat(ssh): verify host keys, and restore panes correctly across a reconnect (#14844)
* docs(ssh): design for real host key verification (STA-4319)

Today's ssh2 verifier records a fingerprint and returns true — every host key is
accepted, with no known_hosts consult and no change detection anywhere in
src/main/ssh/. Scope is per-connection, so exec, SFTP, port forwarding, the
watcher and relay deploy all ride that one unverified handshake, and the
ProxyJump path puts the final hop — the topology most likely to cross untrusted
network — on ssh2 specifically.

Decisions worth calling out:

- Read the user's known_hosts as a trust source but NEVER write to it. That file
  is shared with every other SSH tool on the machine; appending means line
  endings, permissions, concurrent writers and a corruption blast radius well
  beyond us. Accepted keys go to our own per-target store. Reading theirs is also
  the entire migration story: most developers already have their hosts there.
- Mismatch is scoped to the SAME key type. A host with only an RSA entry that
  presents ed25519 is unknown, not changed. ssh2 negotiates ed25519 first, so
  without this we would fire a change-of-key alarm at nearly every existing user
  on their first upgraded connect — training them to dismiss the one warning that
  is supposed to mean something. Flagged in review as the decision I am least
  sure of; a downgrade-vector argument against it is being tested.
- Changed key hard-fails with no override button; recovery is a separate explicit
  action, offered only when OUR store is what disagreed, because forgetting our
  record cannot unblock a known_hosts conflict.
- Background reconnects deny rather than prompt. A dialog the user cannot place
  in context only teaches click-through.

Two traps are documented because either would make the fix silently do nothing:
an async verifier returns a Promise, which ssh2 reads as truthy and accepts
immediately; and the existing test mock invokes hostVerifier with one argument
and ignores the return, so it would pass against a verifier that never decides.

Design only — no behaviour change. The doc is added to the tracked-reference
allowlist in .gitignore alongside the other docs/reference entries.

* docs(ssh): revise the host key design after security and migration review

Three things the reviews changed, kept visible rather than quietly edited out.

THREAT MODEL WAS WRONG IN THREE PLACES. Jump hosts are not the worst case — they
are already safe: shouldUseSystemSshTransport branches on exactly the inputs
resolveEffectiveProxy does, and attemptConnect returns after the system probe, so
ProxyJump goes through OpenSSH and is verified. Agent forwarding was overstated
(gated on the user's ForwardAgent). Credential theft was understated: any auth
error counts as agent fallback, so a MITM walks the user to the password AND
private-key passphrase prompts, and cachedPassword replays without prompting. The
relay claim was backwards — the attacker owns their own machine; the real impact
is the return direction, where they become the host our workspace trusts.

TYPE SCOPING IS A DOWNGRADE VECTOR WITHOUT ALGORITHM ORDERING. This was the
decision I flagged as least certain and asked to have argued both ways. OpenSSH
is safe only because order_hostkeyalgs() puts known types first and RFC 4253
gives the client's order priority. ssh2 negotiates ed25519 first regardless, so
an attacker who cannot forge the RSA key on file just presents ed25519 and gets a
friendly first-contact prompt instead of a hard failure. Keep scoping, but set
algorithms.serverHostKey to lead with the types on file — and add a sixth
outcome for 'unknown type, known host', which must never read as first contact.

SHIP THE DEFENCE BEFORE THE DIALOG. Startup restore fires eager connects for all
targets in parallel with a 15s timeout while a prompt would live 120s; ephemeral
VM targets present a new key every launch; paired-web connects run on the host
desktop, so the dialog opens on someone else's screen. Phase 1 is therefore no
modal at all: consult known_hosts and our store, match connects, unknown persists
with accept-new semantics, mismatch and revoked hard-fail. That is the whole MITM
defence with none of the migration risk.

Also folded in, verified live against OpenSSH 10.2p1: the without-port fallback
(bracketed lookup first, then bare, where the second pass can only yield match or
unknown — otherwise a bare line plus a non-default port produces a spurious
prompt); hashed entries hash the candidate form; multiple files union; a
cert-authority line does not match a plain key. IPv6 and bracket parsing moved
INTO scope — that is a parser requirement, not a scope call, and getting it wrong
produces the prompt-training harm the design exists to avoid.

* feat(ssh): parse and match OpenSSH known_hosts

The matcher half of STA-4319. No behaviour change yet — nothing calls this.

Hand-rolled because no maintained JS implementation exists, and written against
behaviour observed from OpenSSH 10.2p1 rather than inferred from the man page.
Three of those behaviours a reasonable reading gets wrong:

- A non-default port is TWO ordered lookups, not one candidate set: '[host]:port'
  first, then bare host ('checking without port identifier' in ssh -v). The
  fallback pass can only yield match or unknown — OpenSSH downgrades a wrong key
  there rather than reporting a change. Collapse them and anyone holding a bare
  line who connects off-port gets a spurious first-contact result; treat the
  fallback as authoritative and they get a false change-of-key alarm.
- Revocation resolves in its own pass so the verdict cannot depend on line order.
  Verified both orderings.
- A cert-authority line never matches a plain host key; it only validates
  certificates. A normal line alongside it still decides.

Mismatch is scoped to the same key type, and a host known by a DIFFERENT type
returns unknown-type-known-host rather than plain unknown — an attacker who
cannot forge the key on file must not get a friendly first-contact result by
presenting another type. That outcome is only half the defence; the other half
(leading serverHostKey with known types) lands with the wiring.

47 tests from vectors executed against real sshd, including ssh-keygen -H hashed
entries. Each of six mutations reddens it: collapsing the passes, letting the
fallback report mismatch, dropping type scoping, resolving revocation in line
order, honouring an unrecognised marker, and skipping the blob/type agreement
check.

* feat(ssh): decide what to do with a presented host key

The policy half of STA-4319, kept separate from the ssh2 wiring so it is testable
without a handshake and injected rather than importing its sources, so a test
states its own trust state instead of writing files.

Phase 1 ships no dialog — a test asserts the decision is never 'prompt'. Startup
restore opens every previously-active target at once, ephemeral VM targets would
ask every launch, and paired-web connects run on the host desktop where the
dialog would appear on someone else's screen.

Ordering that matters: revocation outranks everything including
StrictHostKeyChecking=no, because a revoked key is a statement that this key is
known-bad rather than merely unrecognised. known_hosts is named before our own
store on a change, because its remedy (ssh-keygen -R) is the one that also
unblocks ssh and git — pointing at a remedy that cannot work is worse than none.

Two carve-outs with reasons: an ephemeral runtime target accepts WITHOUT
recording, since a fresh VM presents a new key every launch and a stored record
would accumulate per launch and eventually read as a spurious change; and when
ssh -G ran on the HOME-divergent path that suppresses /etc/ssh/ssh_config, an
unknown host is denied, because a site-wide policy may forbid it and being laxer
than ssh is the one outcome that is never acceptable.

Rejection text deliberately avoids 'authentication failed' and 'permission
denied': the reconnect ladder classifies on those substrings, so a denial phrased
that way is retried forever against a decision that will never change. Pinned by
a test.

* feat(ssh): build the host key verifier and the algorithm order that makes it safe

Still not wired into the handshake — that lands next. This is the piece that
turns a decision into an ssh2 callback, plus the half of the design that is easy
to forget because it lives in a different config field.

The verifier MUST be a plain function returning undefined. ssh2 does
'const ret = verifier(key, verify); if (ret !== undefined) verify(ret)', so an
async function returns a Promise — neither undefined nor falsy — and ssh2 accepts
the key immediately while ignoring whatever the callback later decides. Making
this async would silently restore exactly the accept-everything behaviour the
module exists to remove, so a test asserts the return value is undefined.

orderServerHostKeyAlgorithms is what makes type-scoped matching safe rather than
a downgrade. RFC 4253 gives the client's algorithm order priority, so leading
with the types we already hold for a host denies a server the choice of
presenting some other type to convert a hard failure into first contact. Without
it, an attacker who cannot forge the key on file just offers a different
algorithm. Revoked entries never contribute to that order.

Also fails closed on two paths that would otherwise hang or over-trust: a key
whose own length-prefixed header cannot be read is refused rather than reasoned
about, and a throw from any dependency denies, because ssh2 may not catch an
exception raised inside the verifier and the handshake would hang instead of
failing.

18 tests. Includes the two negative cases that matter — first-contact keys are
recorded, but keys we already know, rejected keys, ephemeral runtime targets and
a lax StrictHostKeyChecking are not.

* fix(ssh): promote every RSA signature algorithm for a known ssh-rsa key

A known_hosts entry names the KEY type, which is not the negotiated ALGORITHM
name. One ssh-rsa key is offered as rsa-sha2-512, rsa-sha2-256 or ssh-rsa
depending on the signature algorithm, so matching the literal name only would
leave a host we know by RSA ordered behind ed25519 — precisely the ordering this
function exists to prevent, and precisely the population (RSA-era known_hosts
entries) it was written for.

Verified from ssh2's own negotiation while wiring this: kex.js iterates the
CLIENT list and takes the first entry the server also offers, so client order
does decide, as RFC 4253 says. ssh2's default order leads with ed25519 and places
the RSA algorithms fifth through seventh.

* fix(ssh): verify host keys instead of accepting every one (STA-4319)

The actual fix. ssh-connection's verifier recorded a fingerprint and returned
true, so every ssh2 connection accepted every host key — no known_hosts consult,
no change detection. It now consults the user's known_hosts plus our own store
and refuses a changed, revoked or unverifiable key.

Phase 1 by design: no dialog. Unknown hosts are accepted and recorded
(accept-new semantics), because startup restore opens every previously-active
target at once, ephemeral VM targets present a new key each launch, and
paired-web connects run on the host desktop where a prompt would appear on
someone else's screen. The MITM defence lands now; the prompt is Phase 2.

Also sets algorithms.serverHostKey to lead with the types already known for the
host. Without it the type-scoped matching is a downgrade — an attacker who cannot
forge the key on file just presents another type and turns a hard failure into
first contact. Verified from ssh2's kex.js that the client list decides.

Denial replaces ssh2's generic handshake error with the specific reason, because
the reconnect ladder cannot distinguish a generic failure from a transient fault
and would retry forever against a decision that will never change.

An unreadable trust store degrades to known_hosts only rather than failing the
connect: a changed key is still refused, and a host trusted only by us falls back
to first contact and is re-recorded, reaching the same decision.

The ssh2 mock now uses the callback form and aborts the handshake on denial. As
written it called hostVerifier(key) with one argument and ignored the result, so
it would have passed against a verifier that never decides — flagged in the
design as a mock that had to change, not a test to quietly rewrite. Two new tests
pin the wiring rather than the module: an unidentifiable blob is refused, and a
well-formed key is accepted.

Note for review: commit 2d2a0880ba unintentionally swept in two modules built
concurrently (ssh-known-hosts-source, ssh-host-key-store) because I staged with
'git add -A'; its message describes only the verifier. Both are covered by their
own tests, but the attribution in that commit is wrong.

1461 SSH tests pass.

* fix(ssh): bind the host key store to the active profile at startup

Without this the store reports nothing trusted on every launch. Safe — known_hosts
still decides, and a host trusted only by us degrades to first contact and is
re-recorded — but it silently discarded our own accept records, so the store the
design calls for was not actually in use.

Bound beside the profile Store, since it is a sidecar of the same data file.

Also records why the paired-web carve-out the migration review asked for is NOT
implemented in Phase 1, rather than leaving it looking forgotten. That carve-out
exists to stop a web client waiting out the 120s prompt timeout — a hang only
reachable if a prompt exists, and Phase 1 has none, which the decision function
pins with a test asserting it never returns 'prompt'. An RPC connect therefore
behaves exactly like a local one. Adding a fail-fast path now would introduce a
failure mode for a hang that cannot occur; it becomes load-bearing when the
dialog lands and is listed under Phase 2.

Noted there for whoever builds Phase 2: runtime/rpc/methods/ssh.ts already
swallows the specific error and rethrows a generic one, so the host-key reason
will not reach a web user without a change there too.

Full unit suite: 52,352 pass. The 8 failures are the known environment baseline
(5 osc8, 2 IME) plus one browser-cookie suite-ordering flake that passes in
isolation — none in src/main/ssh, and none related to this change.

* fix(ssh): close two downgrades the implementation review found

Both were in my wiring, not the design, and two reviewers found the first
independently.

1. OUR STORE WAS TYPE-DOWNGRADABLE. The inline lookup filtered by key type first
and could only answer match/mismatch/unknown, so a record of a DIFFERENT type for
the same endpoint read as "unknown". A host learned on first contact — ed25519,
since ssh2 proposes it first — and absent from known_hosts could then be
impersonated by presenting RSA: both sources say unknown, so accept-and-remember,
silently. That is exactly the downgrade D3 says the design cannot ship without,
applied to the records we create ourselves. The store's own isTrusted already
computed the right answer and had no production caller. Stored types now also
feed the algorithm ordering, without which the guard is only half present.

2. WE KEYED ON THE ORCA LABEL, NOT THE DIALED HOST. "ssh -G" echoes its own
argument back as its hostname field when no Host block matches, so for a manual
target that field IS the Orca label — the one name D2 forbids keying on, and one
ssh never wrote. We consulted no entries at all, so an impersonated host read as
first contact. Now keys on the dialed host, which buildConnectConfig has already
resolved through HostName, with HostKeyAlias still winning.

That inverted an existing test rather than deleting it: "uses the resolved
hostname, never the Orca label" encoded an assumption disproved against OpenSSH
10.2p1, so it is renamed and reversed with the reason recorded in the test.

3. NO READABLE SOURCE IS NOT FIRST CONTACT. Every known_hosts file failing to
read was indistinguishable from "this host is unknown", so a changed key would be
accepted the one time we could not check. The loader now reports how many files
it could read, and zero readable sources with an empty store takes the strict
path instead of recording trust.

4. A superseded attempt's rejection could replace the live attempt's error, and
substituting a new Error drops ssh2's code, so a transient ECONNRESET would stop
being classified as retryable. The rejection is now local to its attempt.

5. displayHost was the Orca label, so a mismatch could print
"ssh-keygen -R <label>" — a remedy that removes nothing.

Also adds the tests that would have caught 1 and 2, the stale-attempt denial, and
IPv6 literals, which the design moved into scope and nothing covered.

1,469 SSH tests pass.

* fix(ssh): stop offering credentials to a host we just refused

A refused host key ended the handshake and then fell into the credential
ladder, because ssh2 reports a denied key as a generic auth failure and the
passphrase branch is eligible on message shape alone whenever an encrypted
identity file is configured. So the sequence was: decide this host may not be
who it claims to be, then ask the user for their passphrase and hand it to it.
Failing that, prompt for a password. Failing that, retry over the system ssh
binary, which for a disagreement with our own store rather than known_hosts
would simply connect.

That inverts the point of checking at all. A denied key is now final for the
attempt: recognised by type before any fallback runs, and again inside the
agent-fallback retry, which re-runs the handshake and so can be the attempt that
denies.

Two things had to change for that to hold.

The rejection is now a HostKeyVerificationError rather than a rebuilt Error, so
the connect path recognises it by type. Substring matching would have worked
today and quietly stopped working the first time a reason string was reworded —
and these strings are already worded around the auth-error classifier, so they
are exactly the kind that get edited.

And the verifier now reports the denials that skipped the policy: an
unreadable key blob and an internal failure both denied without calling
onDecision, so the connect path saw only ssh2's generic failure and walked the
ladder. That was the actual path the new test hit first. The report carries no
fingerprint, since there is no host key to identify, and the connection no
longer overwrites the fingerprint it holds with an empty one — the relay keys
install-lock isolation on that value.

The reconnect ladder also refuses to retry it. Its classifier is otherwise
substring-driven, so a reason containing "connection reset" would have been
retried until the ladder gave up, burying the reason under nine attempts.

Tests: no credential prompt after a refusal, an encrypted key configured so the
passphrase branch is eligible; refused reports as 'error', not 'auth-failed',
which would invite the user to re-enter credentials that are not the problem;
no retry; both bypassing denials report; a throwing listener still denies rather
than hanging the handshake.

Also drops three test files a bisect resurrected from before the revert that
deleted them.

172 tests pass across the three touched files; typecheck and lint clean.
Pre-existing on origin/main and untouched here: 3 failures in
ssh-connection-sftp-namespace.test.ts.

* fix(ssh): stop treating an absent known_hosts as a source we failed to read

The previous commit's "no readable source is not first contact" guard was right
about the danger and wrong about how to detect it, and the version that shipped
would have refused every connection a new profile ever makes.

A file that does not exist and a file that refuses to open both arrive as a
rejected readFile, and I counted them the same way. They are opposites. An
absent known_hosts is the normal state — ssh creates it on its own first connect,
and an Orca profile that has never connected has none — and it is real evidence
that no host is known. A file that exists and will not open is evidence withheld:
the entry that would have said "this key changed" may be sitting in it.

The default list is the reason this was fatal rather than obscure. It always
names known_hosts2, which essentially never exists, so on a machine with a normal
known_hosts the count was 1-of-2 and everything worked; on a fresh profile it was
0-of-2 and every connect failed with "the system SSH configuration could not be
read" — a message about a file the user does not have and an error they cannot
act on. The suite passed only because the machine running it happens to have a
known_hosts. Pointing HOME at an empty directory fails 62 connection tests, which
is what the new wiring test does.

So the count is now unreadableFileCount: files that exist and could not be read,
which is the condition the guard was always trying to express. Any one of them
takes the strict path; an absent or empty file takes none. The store clause is
gone with it — a store hit already returns accept before this is consulted, so it
never changed an outcome.

Tests: absent, empty, permission-denied, directory, and all-parsed at the source;
first contact with no known_hosts at all at the wiring level.

1,482 SSH tests pass. The 3 failures in ssh-connection-sftp-namespace.test.ts are
pre-existing on origin/main and untouched here.

* fix(ssh): let an ephemeral runtime outrank sources we could not read

Three things, all about the same question: when we cannot see everything that
decides a host key, what does that actually license us to refuse?

1. ON-DEMAND RUNTIMES WERE REFUSED FOR A POLICY THEY COULD NEVER SATISFY.

A machine provisioned a minute ago cannot be in known_hosts, by construction —
which is why it has a carve-out at all. But the carve-out sat BELOW the
incomplete-sources check, so anyone whose HOME diverges from their passwd home
(sandboxes, our own E2E isolation) took the `-F` path, and every on-demand
runtime connection was refused, pointing at a config file the user cannot fix.

Refusing there buys nothing. No policy, seen or unseen, is satisfiable by a host
that did not exist yesterday; the trust comes from the provisioning channel. So
the carve-out now outranks it. An EXPLICIT StrictHostKeyChecking=yes still wins
over both — that one we can read, and the user asked for it.

2. THE FLAG WAS NAMED FOR ONE OF ITS TWO MEANINGS.

`siteConfigSuppressed` started as "-F hid /etc/ssh/ssh_config" and had since
grown "a known_hosts file exists and would not open" — which is not a site
config, and reading the name in the decision function told you nothing about
why an unreadable file landed there. It is `verificationSourcesIncomplete` now:
we could not see something that decides this, so do not extend NEW trust. A host
we already know still connects, because a match is decided before this is
reached, and that is now pinned by name.

3. THE COPIED ssh2 ALGORITHM LIST HAD NO DRIFT ALARM.

We reorder ssh2's default host-key proposal, which meant hand-copying a list
ssh2 exports only from a deep internal path. ssh2 throws `Unsupported algorithm`
on anything outside its supported list, so drift does not degrade — every
target stops connecting, before a socket opens, with a message about an
algorithm the user never chose. Worth knowing that the list is also built
conditionally on ed25519 support.

Kept as a literal rather than a deep import, since silently adopting a new
proposal order is the wrong default — the order is what makes type-scoped
matching safe, so a change deserves review. A test now compares it against
ssh2's real constant and checks every entry is one ssh2 accepts. Moved next to
the ordering function it feeds, and out from between the import statements.

1,488 SSH tests pass. The 3 in ssh-connection-sftp-namespace.test.ts are
pre-existing on origin/main.

* docs(ssh): record what review changed and the STA-4319 follow-ups

The design survived implementation; every defect found afterwards was in the
wiring. Worth recording the pattern, because it repeated five times: each one
made us either blind or unusable, never subtly wrong — and four of the five
broke legitimate hosts rather than admitting bad ones.

Action items separate the things Phase 2 must decide (UpdateHostKeys, which is
now the likeliest way a legitimate user meets a rejection; the web client never
seeing the reason; RPC fail-fast) from the gaps Phase 1 knowingly accepts (WSL,
CheckHostIP, ca-only hosts, the hand-copied ssh2 algorithm list).

Also flags the rollout risk plainly: this is the first release in which Orca can
refuse an SSH connection at all.

* test(ssh): pin the ephemeral carve-out where it is actually observable

The first version of this test asserted an on-demand runtime connects on first
contact, which every target does — it would have passed with the carve-out
deleted. Pointing HOME at a home whose known_hosts exists and will not open makes
the two cases diverge: a normal target is refused there, an on-demand runtime is
not. Removing the carve-out now fails three tests instead of none.

Also covers the wiring for the unreadable-source refusal itself, which until now
was only pinned at the decision level.

* test(ssh): pin the parser against real ssh -G output, and accept-new against ask

Two gaps the audit named.

The ssh -G fixtures were all hand-written, which means they encode what I expect
ssh to print. This one is verbatim OpenSSH_10.2p1 output for a Host block using
HostName, HostKeyAlias, StrictHostKeyChecking accept-new, two UserKnownHostsFile
paths and a non-default port. The format detail that matters: each file list
arrives space-separated on ONE line, so reading it as a single path would consult
nothing for anyone with more than one file configured.

And accept-new was untested despite being a real OpenSSH value. It currently
behaves identically to ask, which is exactly right while no dialog exists and is
what lets the defence ship without a modal — so the equivalence is now pinned,
and Phase 2 has to break it deliberately rather than discover it. Plus a guard
that accept-new never falls into the strict branch.

* test(ssh): cover the wire from an accepted key to a record on disk

Nothing covered the store end to end. Every connection test runs with it
unwired — a real path, since it degrades to known_hosts only — so an accepted
first-contact key was never observed becoming a record, and the record was never
observed being believed on the next connection. Without that wire the store is
dead weight: an unknown host connects every time and is never learned.

Its own file for two reasons. initSshHostKeyStoreFile binds module-level state
for the rest of the process, and binding it makes the connect prelude do real
disk I/O — which the shared suite cannot absorb, because its reconnect tests
drive the clock with fake timers and an fs round trip does not complete inside an
advanced tick. Adding these there turned 12 unrelated tests red.

Covers: the record is written; it is read back as a match; the same key is not
recorded twice; a DIFFERENT key for a host we recorded ourselves is refused
(the store's entire security value, and a case known_hosts cannot catch since it
has never heard of the host); and an on-demand runtime records nothing.

Stubbing rememberHostKey to a no-op fails four of the five.

* fix(settings): stop truncating the SSH connection error to one line

The host key messages are written to be actionable — a mismatch ends in
"Run: ssh-keygen -R <host>", which is the remedy that also unblocks ssh and git.
The only place in the renderer that displays an SSH connection error clamped it
to a single line with `truncate` and carried no title attribute, so the remedy
was unreachable, not even on hover. The careful wording reached a CSS ellipsis.

Wraps instead, with [overflow-wrap:anywhere] because a long hostname offers no
break opportunity and would overflow the column on its own. The paragraph only
renders on failure, so the extra height costs nothing in the normal case.

CORRECTION to the four preceding commits: they each claimed 3 pre-existing
failures in ssh-connection-sftp-namespace.test.ts. That was my error — I had been
running `npx vitest` without `--config config/vitest.config.ts`, so the project's
setupFiles and execArgv were absent. Under the real config those 3 pass, and have
throughout. The whole SSH suite is green: 1,504 passed, 13 skipped.

Still failing on this branch and unrelated to it (different subsystems, no file
overlap): 5 in terminal-snapshot-osc8-roundtrip and 2 in browser-cookie-import.

* fix(terminal): show why the SSH connection failed, not just that it did

The reconnect overlay took only a status, so every failure rendered the same
sentence: "The SSH connection to devbox failed. Connect again to continue this
terminal session." A refused host key and a network timeout were indistinguishable
there, and the host key message — the only place that names the remedy, down to
`ssh-keygen -R <host>` — reached no terminal user at all. The state carried it the
whole way; the overlay simply never asked for it.

Adds it as a second line rather than replacing the sentence. The sentence says
what to do, the detail says what happened, and keeping both means an errno
failure does not lose its guidance to make room for "connect ETIMEDOUT". Wrapped,
for the same reason as the settings card: the remedy is at the end.

Suppressed for a removed target, which already explains itself and can never
reconnect — a stale connection error underneath would contradict it.

The new selector mirrors selectRuntimeAwareSshStatus branch for branch, including
the unreachable-environment and un-hydrated-bucket nulls, so the pair cannot
disagree about which source they read and a detail is never shown next to a status
it did not come from.

Known and NOT addressed here: "Connect again" is still the wrong advice for a
decision that will never change. Telling those apart needs a typed reason on the
wire rather than a string, which is a remote-wire-compatibility decision; it is
recorded in the STA-4319 follow-ups.

Pre-existing on this branch and untouched: 2 failures in
terminal-ime-xterm-resumed-preedit-visibility.

* docs(ssh): record where the rejection message actually lands

Traced end to end, because a rejection the user cannot read is a half-shipped
feature — and two of the surfaces were dropping it entirely, both now fixed.

What is left is written down rather than guessed at: the status bar renders only
'Error', the terminal overlay's call to action still invites a retry that cannot
succeed, toasts carry Electron's remote-method prefix, and the paired-web path
replaces the text with 'SSH connection unavailable' on every route — which also
affects a DESKTOP user viewing a host owned by a remote Orca server, not just web
clients.

* refactor(ssh): give the store one matcher instead of two

The connect path had its own copy of the store comparison, because ssh2's
verifier decides synchronously and cannot await the file, so records are
preloaded. That copy is precisely where the type downgrade came from: it answered
only match/mismatch/unknown, so a record of a different type for the same
endpoint read as first contact and a host learned on first contact could be
impersonated by presenting another key type. Fixing it left two implementations
that have to agree forever, which is the same bug waiting to happen.

matchTrustedHostKeys is now the single pure matcher; isTrusted is a load plus a
call to it, and the connect path calls it directly on preloaded records. Same for
the key types that feed the algorithm ordering, which the connect path was also
filtering by hand.

The two copies had in fact already drifted: the connect path lower-cased the
query host where the write trims AND lower-cases. Not reachable today — the host
is trimmed before it reaches there, which I confirmed by mutating the
normalisation and watching the wiring test pass anyway. So this is not a bug fix,
and the wiring-level test I first wrote for it proved nothing and is gone. The
unit test that replaces it drives the matcher directly, where the input is mine
to control, and it does fail when the normalisation diverges.

Also adds an equivalence test across all six outcomes between the preloaded and
awaited paths, so the two can never answer differently again.

Restores the local name siteConfigSuppressed for the `-F` check; it was renamed
along with the decision input, but at that site it really does mean only the one
thing, and the union with the unreadable-file count happens one line later.

1,509 SSH tests pass.

* test(ssh): check the matcher against a live OpenSSH client, not against my beliefs

Every other test in the parser file states what I believe ssh does. These state
what it did: an OpenSSH 10.2p1 client against a real sshd on 127.0.0.1:2222, with
the client's own verdict recorded from its output, and ssh-keygen -H's own salt
and hash pinned as a vector.

Two assumptions the design leans on were worth more than an argument.

THE FALLBACK PASS MAY NOT REPORT A CHANGE. We look up `[host]:port` first and
retry the bare host, and only the first pass may answer `mismatch`. With
StrictHostKeyChecking=accept-new, a bare line holding a DIFFERENT key, dialed on
2222, ssh connected and appended a new `[127.0.0.1]:2222` line — no
IDENTIFICATION HAS CHANGED banner. It read that as first contact. Had we reported
a change there we would refuse hosts ssh connects to happily, and the wrongness
would have been invisible: refusing looks like the cautious choice.

AND THE TYPE-SCOPING REJECTION IS NOT AN INVENTION. known_hosts holding an ssh-rsa
key while the server offers ed25519 makes ssh print IDENTIFICATION HAS CHANGED and
refuse. So unknown-type-known-host is neither stricter nor laxer than ssh —
treating it as first contact, which is what a naive type-scoped lookup does, is
the laxer mistake.

That second result also fixes the message. ssh is blocked too, so
`ssh-keygen -R <host>` is the remedy that unblocks both, and we were naming it
only for a same-type mismatch — leaving this case with a diagnosis and no way
out. Named now when known_hosts is the source that disagrees, and still not named
when it is our own store, which ssh-keygen would not touch.

1,516 SSH tests pass.

* docs(ssh): record the two assumptions a live client confirmed

Both were load-bearing and neither was obvious: the bare-host fallback pass may
not report a change, and unknown-type-known-host is what ssh itself does rather
than something we invented. Getting the first backwards would have refused hosts
ssh connects to happily, which is the failure mode that looks like caution.

* style(ssh): satisfy the code-quality lints in the two new test files

A string concatenation that should be a template literal, and an inline
import() type annotation that should be a type-only namespace import — erased
before vi.mock's hoisted factory runs, so the mock is unaffected.

pnpm lint is clean.

* fix(ssh): honour StrictHostKeyChecking, which had never once been read correctly

`ssh -G` does not echo the value the user wrote. StrictHostKeyChecking is
rendered through fmt_multistate_int, which prints the first entry of
multistate_strict_hostkey, and that table lists true/false before yes/no:

  yes -> true | no -> false | off -> false | accept-new -> accept-new | ask -> ask

Verified against OpenSSH 10.2p1 from both a config file and -o. Not
10.2-specific; the table ordering is old.

The decision function tested only 'yes'/'always' and 'no'/'off' — spellings that
cannot arrive. So `StrictHostKeyChecking yes` fell through to the default branch
and we accepted AND PERSISTED a host the user's config explicitly says to refuse.
That is the worst outcome this feature can produce, and it was the behaviour for
every strict user from the first commit. `no`/`off` landed there too, breaking
the documented "lax settings never persist" invariant.

Every unit test passed throughout, because they fed the function 'yes' — the
value a human writes, not the one that reaches the code. My ssh -G parity test
did capture real output, but I happened to configure accept-new, one of only two
values that round-trip unchanged. The new table is keyed on configured value ->
what ssh -G actually prints, and asserts both reach the same verdict, so the
question "is this the spelling that arrives?" cannot be assumed again.

Found by a parity review against a live OpenSSH client.

* fix(ssh): stop the fallback pass accepting a changed key, and refusing a new one

One wrong loop scope, two opposite errors, both reproduced against a live
OpenSSH 10.2p1 client and an ed25519-only sshd on 127.0.0.1:2223.

ACCEPTING A CHANGED KEY. ssh runs the bare-host fallback only when the
port-qualified lookup matched no plain entry of ANY key type. We ran it unless
pass 0 produced a match or a SAME-TYPE mismatch. So with an off-port RSA entry
plus a bare, correct ed25519 line — an ordinary shape, an old off-port entry
beside one written by a port-22 connect — ssh printed IDENTIFICATION HAS CHANGED
and refused, with no "checking without port identifier" in -v because the
fallback never ran, while we reached the bare line and returned `match`.

REFUSING A NEW ONE. sawKnownHostOtherType and sawCertAuthority were declared
outside the pass loop, so an entry found only on the fallback pass could set
them. A bare ssh-rsa entry, dialed on a non-default port against an ed25519-only
server, made ssh add the host and connect — plain first contact — where we
returned unknown-type-known-host and hard-failed. That is Gitea/Forgejo, dev
containers, Gerrit, Vagrant: an off-port service on a host already in
known_hosts.

So the flags are per-pass now, and pass 0 decides as soon as it finds any plain
entry for the host. Which of the two rejections it reports only picks the
message; ssh calls both HOST_CHANGED.

Also drops the type check from the match test: byte equality already implies the
types agree, because the blob carries its own algorithm name and parsing rejects
any line whose declared type disagrees with it.

Reverting either half of the scope fix fails exactly the two new tests.
1,526 SSH tests pass.

* fix(ssh): refuse the known_hosts lines ssh itself refuses to parse

Three ways a line could be trusted by us and invisible to the user's own ssh —
or, worse, raise a CHANGED alarm from an entry ssh drops.

Buffer.from does not fail on bad base64, it SKIPS invalid characters, so
`<key>!!!` and a blob with `@@` spliced into it both decoded to the correct key
and matched. Verified live against OpenSSH 10.2p1 on 127.0.0.1:2224: the
unmodified control reached authentication and all three malformed variants
produced "No ED25519 host key is known". A re-encode-and-compare makes us agree.

`<key>AAAA` is the interesting one, and the reason the first fix was not enough:
68 characters plus 4 is still legal base64, and the algorithm header still reads
ssh-ed25519, so neither the base64 check nor the existing header check sees
anything wrong. ssh parses the whole key structure. We decoded 54 bytes where an
ed25519 key is 51 and reported `mismatch` — a man-in-the-middle warning caused by
a typo in a file ssh silently ignores.

So the blob is now walked as what it is: a run of length-prefixed fields that
must consume it exactly. Algorithm-agnostic on purpose, so a key type we do not
model is checked as well as one we do. It also rejects a length prefix that
overruns the buffer, which readHostKeyType only checked for the first field.

And ssh's extract_salt demands exactly one SHA1 digest — "expected salt len 20,
got 16" — where we accepted any non-empty salt. A short salt is still a usable
HMAC key for us, so a hand-crafted line could match for us and be a parse error
for ssh. ssh-keygen -H always writes 20 bytes, so refusing loses no real entry.

Worth recording that ssh-keygen -F cannot answer any of this: it matches host
names and prints lines without ever decoding the key, so it reports "found" for
all four blobs. The real client was the only instrument that worked.

Found by a parity review; the base64 finding as reported was right about the
behaviour and wrong about the mechanism for the padded case, which is what led to
the structural check.

1,533 SSH tests pass.

* fix(ssh): name a ssh-keygen -R target that actually removes the entry

Verified against OpenSSH 10.2p1: with both `[h.example]:2222` and `h.example`
on file, `ssh-keygen -R h.example` removes only the bare line and leaves the
bracketed one — and there is no port flag, `-R host -p 2222` is "Too many
arguments". An off-port target is keyed `[host]:port` in known_hosts, so the
command we printed removed nothing: the user runs it, reconnects, and meets the
identical failure with no indication of why.

The message now names the bracketed form, quoted because the brackets are shell
glob characters, whenever the port is not 22.

Found by a parity review.

* fix(ssh): read ssh2's host key algorithm list instead of copying it

ssh2 builds DEFAULT_SERVER_HOST_KEY at load time and prepends ssh-ed25519 only
when a RUNTIME PROBE succeeds — it signs and verifies with a fixed Ed25519 key.
On a build where that probe fails, ssh-ed25519 is absent from ssh2's SUPPORTED
list too, and generateAlgorithmList throws `Unsupported algorithm: ssh-ed25519`
from inside client.connect. That throw matches no retry classifier and no
transport-fallback classifier, so it is permanent — and because we only set
`algorithms` for hosts we already know, it would fire on trusted hosts while new
ones kept working. A copied list cannot be merely stale here; it can be wrong.

So it is read from ssh2 now, which also removes the drift risk the previous
commit could only report. ssh2 is external in the main bundle and the bundle is
CJS, so the deep path resolves at runtime from packaged node_modules.

The copy stays as a fallback in case a future ssh2 moves the file — losing the
proposal order degrades the type-scoping guarantee, but refusing to connect at
all is worse. The test that used to pin the copy against ssh2 now pins the
fallback, which is the only part that can still drift.

Found by an availability review.
pnpm lint clean; 1,537 SSH tests pass.

* fix(ssh): look a HostKeyAlias up the way ssh does — without the port

HostKeyAlias suppresses the port entirely. Verified against OpenSSH 10.2p1 on
port 2225 with HostKeyAlias=myalias: an entry keyed `myalias` authenticates, and
one keyed `[myalias]:2225` gives "No ED25519 host key is known for myalias". We
built [['[alias]:port'], ['alias']] and consulted a form ssh never writes.

On its own that was a stale-entry false alarm. The previous commit made it worse:
now that the first pass decides as soon as it finds any entry for the host, a
leftover `[alias]:2225` line STOPS the bare lookup ssh actually performs — so the
one population D2 cites HostKeyAlias for, bastions tunnelled through
localhost:port, would get a hard failure on a host ssh connects to.

So resolveKnownHostsLookupHost reports whether the name came from the alias, not
just what it is, and that flag reaches both the matcher and the algorithm
ordering. Returning the name alone is what made the bug invisible: the caller had
no way to know it was holding something that must not be bracketed.

Found by a parity review.
1,543 SSH tests pass.

* fix(ssh): only claim the site config was suppressed when ssh -G actually ran

sshGArgsForHost reports which arguments WOULD be used, not what happened. It
returns the -F form whenever ~/.ssh/config exists and os.homedir() diverges from
the passwd home, so a machine with no usable ssh at all — Windows without
OpenSSH, a restricted sandbox, a timed-out probe — was judged by whether it
happens to have a ~/.ssh/config, and rejected every unknown host permanently if
it did. The same broken machine WITHOUT one stayed fully permissive, which is the
tell: the flag is a claim about a config file we could not read, and when ssh
never ran there is no such claim to make.

Narrow but total where it lands: anything that sets HOME explicitly (wrapper
scripts, sudo -E, devcontainers), macOS mobile and network accounts, and the E2E
isolation this branch was written for.

Found by an availability review.

* fix(ssh): stop refusing hosts that ssh itself connects to

Two product decisions, both taken deliberately after a review priced their blast
radius, and both moving us from stricter-than-ssh to matching it.

CERTIFICATE-AUTHORITY HOSTS NO LONGER FAIL. The point of an SSH CA is that the
client holds ONE line — very often `@cert-authority *` — instead of per-host
entries. That line matches every candidate, so for a Teleport / Vault-SSH /
Smallstep / in-house-CA user EVERY target failed, not just CA-signed ones,
including on-demand runtime VMs, and StrictHostKeyChecking=no did not help. The
documented escape was an environment variable, which an Electron app launched
from the Dock or Start Menu never sees. Meanwhile OpenSSH, verified live, treats
a CA-covered host presenting a plain key as first contact and connects: ssh2
cannot validate certificates at all, so refusing bought nothing ssh was not
already giving up. The residual risk is real and accepted — for a CA-protected
host we take a plain key we cannot tie to the CA — and the ca-only outcome is
carried through the decision so it stays visible in the log.

AN UNREADABLE known_hosts NO LONGER REFUSES EVERYTHING. Any non-ENOENT read
error on any configured file rejected every unknown host, with a message blaming
the system SSH configuration, which was not what happened. The common trigger is
not exotic: a Windows OneDrive Known Folder Move placeholder while offline fails
with a cloud-file error, not ENOENT. It was also asymmetric with our own store,
which degrades an unreadable file to "nothing trusted" and connects. We now
connect as ssh does — it warns and treats the host as unknown — but record
NOTHING, so a first contact we could not check never becomes durable trust. That
second half is the reason the first is acceptable, so it is pinned end to end
with the store actually bound.

Which meant splitting verificationSourcesIncomplete back apart. It had been one
flag for two claims that now diverge: "a site policy may exist that we cannot
read" still refuses, "a file we could not open may contradict this" does not.
Merging them was what made the second inherit a strictness only the first
justified.

Both still lose to evidence we DID read: a mismatch, a revoked key, or an
explicit StrictHostKeyChecking still refuse in either state.

pnpm lint clean; 1,549 SSH tests pass.

* docs(ssh): correct the design where a live client disproved it

D2, D3 and D4 each stated something about OpenSSH that turned out to be wrong
when tested against a real client and sshd rather than read from the source.

D3's premise is the notable one: OpenSSH is not type-scoped at all, so it does
not avoid the RSA-era false alarm the way the doc claimed. It avoids the
situation via order_hostkeyalgs and hard-fails when the situation arises anyway.
The conclusion survives — the ordering is still what makes our scoping safe —
but for a different reason than the one written down, and a reader would have
drawn the wrong lesson.

D2 gains the two rules that actually bite: the entry condition to the fallback
pass, and HostKeyAlias suppressing the port. D4 records both reversals with their
reasoning and the residual risk each one accepts, and the ssh -G spelling trap
that made StrictHostKeyChecking dead on arrival.

Corrections are kept visible rather than edited out, per the note at the top of
the file.

* fix(ssh): repaint the panes after a reconnect, not just reattach them

Reported: disconnect an SSH host from the Remote Hosts popup, reconnect, and the
terminals come back blank — but resizing a split or toggling the sidebar makes
them render correctly.

That last detail is the diagnosis. The panes were never broken: reattach restores
each pane's buffer but not its painted frame. xterm repaints on a write or a
resize, and a reconnect produces neither for a pane that was already correctly
sized — so nothing paints until a relayout forces it, which is exactly what
resizing or toggling the sidebar does.

The renderer already has refitAndRefreshAllTerminalPanes for this shape ('after
bulk desktop restore, background panes may have correct cols/rows but a stale
xterm renderer until focus forces a repaint'). Its only callers were the mobile
fit-reclaim paths; the SSH reconnect path never used it.

Scheduled from finalizeHydratedTerminalPanes, on both a frame and a 100ms settled
pass — the same pattern the desktop-restore path uses, because rAF alone lands
while panes are still remounting.

Mutation-proved: removing the schedule reddens the new test, which is the
reported symptom.

* fix(ssh): repaint background-tab panes revealed after a reconnect

Completes 834a495038, which only fixed the ACTIVE tab. Reported: split panes of
plain shells on another tab were still blank after reconnect until a divider drag
or a sidebar toggle.

The repaint did reach background managers — they stay mounted, only
rendererVisible flips — but it could not land. A tab-hidden pane measures as a
0-size box, so canMeasurePaneForFit bails and the fit is a no-op, and
refreshAllPanes marks rows dirty on a pane with no presented frame, which cannot
repair a grid the reattach's direct terminal.resize left diverged. The reveal
then takes the light resume path, which deliberately does not fit, and
scheduleRevealRepaint only reattaches WebGL. So nothing ever fixed the geometry —
and a divider drag or sidebar toggle is a real fit, which is why those appeared
to work.

Parks the repaint on a hidden manager and replays it on reveal, reusing the
existing reveal-fit machinery rather than adding a mechanism. Flag-gated so the
light path still does not fit in the ordinary case — 'does not fit on a light tab
reveal' stays green.

Splits are not special: the gap is per-manager, so it is identical for 1 or N
panes. Splits just expose it, because users find the workaround (drag a divider)
that a single full-tab pane rarely gets. A never-mounted tab is unaffected — it
has no live manager and fits through the normal initial-fit lifecycle.

Mutation-proved twice: removing the deferral, and reverting the reveal-side
condition. Each reddens only the new tests.

* test(terminal): pin that panes are PAINTED, not merely bound — and fix a broken commit

Two problems, both mine.

1) 0103a80b48 swept in an untracked fixture and left the branch failing
typecheck (unused Terminal import in painted-pane-fixture.ts). Its canvas stub
also threw 'clearRect is not a function' on every refresh. Fixed here.

2) direct-ssh-reconnect-repaint.test.ts, which I wrote to guard the reconnect
repaint, is VACUOUS: it re-implements finalizeHydratedTerminalPanes inside the
test and mocks the registry, so deleting the real fix from useIpcEvents leaves it
green. direct-ssh-reconnect-repaint-wiring.test.ts replaces that guarantee by
capturing the real callback the hook hands the coordinator and running it against
live panes — deleting the two scheduling lines now reddens it.

The gap this closes: content survival was already well covered at the BYTE layer
(snapshot roundtrip, hide/reveal stitching, cold-restore scrollback), but every
pane test stubbed terminal as {cols, rows, refresh: vi.fn()}, so 'repainted' only
ever meant 'a spy fired'. No test ran a real xterm through a real PaneManager.
pane-content-survival.test.ts does, reading .xterm-rows — what the user actually
sees — across reconnect, restart-shaped restore, tab reveal, window show, split
and unsplit, for plain shells and alt-screen TUIs.

The alt-screen distinction is now pinned explicitly: forcing a resize inside
fitAllPanes reddens only the TUI test, because a plain shell reflows and survives
while a TUI frame does not. That asymmetry is why the reported bug looked like a
plain-shell problem.

11 tests, each mutation-proven to redden only its own. 760 pane-manager tests
green; the 2 failures here are the known environmental IME baseline.

Flagged, not fixed: the unsplit path reparents the DOM without the dispose/
reattach that splitManagedPane does explicitly because 'DOM reparenting can
silently invalidate a WebGL context without firing contextlost', and follows it
with a safeFit that no-ops when the box is unchanged. Same shape as the reconnect
bug. happy-dom has no WebGL, so only a real-GPU E2E can confirm it.

* fix(ssh): send the pane its screen back on reconnect

A reconnect left every remote terminal blank. Measured on a live relay, not
inferred: pty.attach returned no replay for every pane, taking the
activation === 'existing' early return in the relay's attach.

'existing' means a source delivery is already open for this client, so it must
already be receiving live output and cannot need its screen re-sent. That holds
for a duplicate attach. It is false for a reconnect, for a reason neither side
can see alone: the client keeps its id across the drop (detachClient refuses to
detach the primary, and setWrite revives that same id) so the delivery outlives
the dead transport, while the RENDERER has already thrown its terminal away. A
reconnect bumps tab.generation, which is the pane's React key, so TerminalPane
remounts and the old xterm is disposed with its buffer, and nothing on that path
captures it first. Both halves are individually reasonable and together they
guarantee a blank pane: the relay reports the client already has the screen, to a
client holding a brand-new empty terminal, and nothing paints until new output
happens to arrive. Resizing appeared to fix it only because a TUI redraws itself.

So the client says which case it is. reattachSshPtySession is by definition
painting into a new terminal, so it asks; nobody else does, and the early return
keeps working for them. Optional on the wire, so an older relay ignores it and
behaves exactly as it does today.

Falling through rather than returning the replay inline is deliberate: the path
below already drops the pending batched bytes that are also in the buffer, which
is what stops the live delivery rendering them twice.

Reproduced first as a test against the real dispatcher, source publication and
PTY handler (the second attach for one client, which is what a reconnect is) and
it fails on the exact symptom before the fix. A second test pins that a caller
which does NOT ask still gets nothing, so this cannot become a double-render for
the duplicate-attach case the early return exists for.

Also updates four provider tests that assert the exact attach params.

NOT yet verified in the running app; the log will show replay=true on reconnect.

* chore(ssh): drop the temporary reconnect-replay diagnostic

Served its purpose: it is what turned 'the panes look blank' into
replay=false, replayLen=0 on every pane, and then into replay=true with real
byte counts once the relay fix landed. The permanent log line keeps the boolean,
which is the part worth having.

* revert: drop the reconnect repaint commits; they cannot fix the blank panes

Reverts 2fdab478c0, 34fc1424f0 and dc6f6bf685, which I cherry-picked onto
this branch to test alongside the host key work.

Their stated premise is 'reattach restores each pane's buffer but not its
painted frame'. That is false for this flow: a reconnect bumps tab.generation,
which is the pane's React key, so TerminalPane remounts and the old xterm is
disposed WITH its buffer, and nothing on that path captures it first. Refitting
and refreshing a terminal whose buffer is empty paints an empty pane. The blank
screen was the relay declining to re-send the scrollback, fixed separately and
verified on screen.

Their tests pass without exercising the real case: the fixture blanks the
painted rows and deliberately LEAVES THE BUFFER INTACT, which is the one
situation that never occurs here, and the hidden-tab test replaces the pane
manager with a stub that reports no panes.

They may still address a separate symptom — a diverged grid after a resize on a
hidden tab — but that is unproven, unrelated to this branch, and the originals
are untouched on nwparker/sta-3077-fix-v3 where they came from. Carrying
unproven renderer changes with a false premise in their message on a
security-focused branch is not worth it.

Reverting first and re-running the full two-step reconnect test is the point:
the earlier verification passed with these present, so it did not establish that
the relay fix stands alone.

* fix(ssh): repaint a reconnected pane from the grid, not a byte tail

A reconnect restored plain shells correctly but was reported to bring full-screen
apps back as fragments of a frame — Claude Code showed a few rules and its cost
line until a resize forced it to repaint.

The two payloads are not interchangeable. Relay replay is a byte TAIL: it can
begin mid-escape, and it misses the alt-screen enter, the clears and the absolute
cursor positioning that built the frame, so replaying it into a fresh terminal
paints whatever fragments survive. The model snapshot is a serialized GRID —
which is what tmux repaints on attach, and the only payload that reliably
restores a TUI.

Orca already had the grid path and already preferred it; it was gated to PARKING.
A reconnect needs it for the same underlying reason a park does: the pane paints
into a terminal holding nothing, because a reconnect bumps tab.generation, which
is the pane's React key, so TerminalPane remounts and the old xterm is disposed
with its buffer. So the gate now admits both, and prepaintParkedSshSnapshot is
prepaintSshModelSnapshot since parking is no longer the only caller.

Deliberately NOT inheriting the parking kill switch: main keeps its headless
model regardless of terminalSshViewParking, so a user who turns view parking off
would otherwise be stranded on the tail.

Every safety gate below eligibility is untouched, and pinned that way: null,
renderer-sourced, sourceless, empty, and escape-tail-only snapshots all still
degrade to relay replay, so widening WHY the model is trusted cannot widen WHAT
is trusted and cannot regress to a blank pane. Reverting either half of the gate
fails three of the new tests.

HONESTY ABOUT WHAT THIS IS VERIFIED TO DO. I could not reproduce the corruption
it targets. Two attempts against a live host, both on a build WITHOUT this
change, both restored correctly: a freshly started Claude Code and Codex side by
side, and an alt-screen `less` scrolled 4000 lines so its original full paint had
aged out of the relay's 100KB tail. The reporter's case also involved pulling
wifi — an abrupt drop rather than a clean disconnect — which is the one variable
I cannot simulate here.

So this is verified to be correct-by-construction and non-regressing: with it
applied, the same scenarios still restore correctly (top live, less at its
scrolled offset in alt-screen, both agent TUIs coherent). It is NOT verified to
fix the reported symptom, because the symptom did not reproduce. Treat the
symptom as open until someone confirms it on an abrupt drop.

Also: top was a poor proxy for a TUI in my earlier verification precisely because
it repaints every second and therefore self-heals within a tick.

* fix(ssh): stop the reconnect prepaint firing after its mount is spent

Regression I introduced with the snapshot-first reconnect paint. The payload path
consumes mountFollowsTerminalPark — it clears the flag after the first reattach
so a later in-place reconnect on the SAME mount cannot repaint. I replaced the
prepaint's read of that mutable flag with a const snapshot of it, so my combined
flag stayed true for the life of the mount. A snapshot could then be written on a
later reattach, into a terminal that already had live content, and its own
isCurrent() guard could no longer go false either.

The visible symptom was a tab that came up blank with no prompt and stayed
generically titled Terminal N — the title only stays generic when the shell never
printed a prompt for Orca to read one from. Every such tab in my session had been
through a remount; four tabs created cleanly with Cmd+T were all fine.

So the flag is mutable again and is consumed alongside the one it was derived
from. Both reasons a mount paints into an empty terminal — a park and a reconnect
— are spent by the first reattach, which is what the original code meant.

Worth stating plainly: my earlier claim that the snapshot change was
non-regressing was tested only against reconnect scenarios. I never exercised
creating a tab afterwards, which is exactly where this showed up.

* test(ssh): cover what a pane SHOWS after a reconnect, and after a new tab

The gap that let both regressions reach a user. Nothing asserted the rendered
pane: the existing SSH coverage checks pty ids, statuses and spy calls, and every
one of those was correct while the screen was blank.

Covers one flow end to end against the dockerized relay: write a marker,
reconnect, require the marker to still be on screen, then open a tab and require
the new shell to answer.

Three choices worth keeping:

A MARKER, NOT A PROMPT. A prompt reappears on its own after a reconnect, so
asserting one cannot tell restored scrollback from a fresh shell. The marker only
exists if the pane kept what it had.

ECHO, NOT EXISTENCE. The new tab must run a command and show its output. A pty
id proves a session was created; it does not prove the pane is usable, which is
the exact distinction the reported bug lived in.

AND THE TAB TITLE. It stays 'Terminal N' only when the shell never printed a
prompt for Orca to read one from, which is what the report showed and the
cheapest signal available.

Gated on ORCA_E2E_SSH_DOCKER=1 like the other relay specs.

* chore(ssh): rename the snapshot prefetch off its park-only name

The probe serves reconnect remounts as well now, so parkedSshSnapshotPrefetch
described only half of what it holds.

* revert: drop the snapshot-first reconnect paint; unproven and it regressed

Reverts e6541fe9b8, its follow-up c497a26788, and the rename f680a0b1cb.

The reasoning behind it still looks right — a byte tail cannot rebuild an
alt-screen application, a grid snapshot can, and that is what tmux repaints on
attach. What I could never do is show it fixing the reported symptom. Two
attempts to reproduce the corruption on a build WITHOUT it both restored
correctly: freshly started Claude Code and Codex, and an alt-screen `less`
scrolled 4000 lines so its full paint had aged out of the relay's 100KB tail.

Meanwhile it cost two real regressions. It fired on mounts that were not
reconnects, leaving a new tab with no prompt and a placeholder title, which a
user hit within minutes. The fix for that consumed the eligibility flag with the
one it was derived from — and after it, a reconnected Claude Code came back as
fragments of a frame, the exact symptom the change was meant to remove. So the
consume-once semantics that stop stale paints and the repaint a reconnect needs
are in direct tension, and I do not yet understand the ordering well enough to
satisfy both.

Shipping an unproven change that has already broken two things twice is worse
than shipping the blank-pane fix alone, which IS reproduced, A/B'd and visually
verified. The TUI corruption goes back to open — but now with something it never
had before: a reproduction. It shows up on a reconnect against a Claude Code
that has been running a while, not one just started, which is why my earlier
checks kept passing.

The e2e coverage stays. It asserts what the relay fix guarantees — a marker
surviving a reconnect, and a tab opened afterwards reaching a shell that answers
— and neither of those depends on this change.

* docs(ssh): name the root cause the reconnect replay fix does not address

requireReplay fixes the blank pane at the symptom. The cause is that a PTY
source delivery is the only per-client relay state that outlives its client
detaching: fs-handler, git-handler and relay-filesystem-watch-registry all
subscribe to dispatcher.onClientDetached and release theirs, and
relay-pty-source-publication never does. The primary client keeps its id across
a transport replacement, so its delivery survives a dead transport and
activate() answers 'existing' to a client that cannot receive anything.

Retiring the delivery on detach is the real fix. Not doing it here is a choice,
not an oversight — it is the flow-control and credit path, and I could not
verify it before handing this over. Recorded in the test that guards the
symptom, which is where someone changing this will actually look.

* docs(ssh): record the three root-cause routes that do not work

I went after the cause and failed three times. Each attempt looks correct until
it runs, so the dead ends are worth more written down than the time they cost:

RETIRING THE DELIVERY ON onClientDetached — the obvious fix, and the one I
argued for, since fs-handler, git-handler and the watch registry all release
their per-client state exactly there. It breaks checkpoint recovery: 10 tests
across relay-pty-source-recovery-interleavings and restore-retry. A delivery
outliving its client is DELIBERATE; that is what lets a reconnecting client
resume from a checkpoint instead of re-receiving everything. This class omits
the subscription on purpose, and that omission is not the bug.

RETIRING WITHOUT session.cancelDelivery() — the credit ledger keeps one upstream
owner per pty, so dropping the record without releasing it leaves the slot
taken and the next open throws 'PTY source delivery already has an upstream
owner'. I saw that live as an error toast over a blank pane.

COMPARING clientGeneration — the delivery identity carries one, but it is
client-supplied through pty.openClient and RequestContext has none to compare
against, so the relay cannot tell the generations apart on its own.

Which points where I would start next, unverified: the SSH client presents the
SAME clientGeneration across a reconnect, so the relay cannot distinguish the new
connection and reuses its delivery. reattachSshPtySession never sends
sourceRecovery at all — the recovery protocol exists and the SSH reattach path
simply does not participate in it. That is likely the real fix, and it is on the
client, not in the relay.

The symptom fix stays because it is verified and the tree is green; the cause
stays open with a map instead of a guess.

* fix(ssh): repaint a reconnected full-screen app from the grid

A reconnected TUI came back as fragments of a frame — Claude Code showed a few
rules and its cost line until a resize made it repaint itself.

Relay replay is a byte TAIL. It can begin mid-escape and it misses the
alt-screen enter, the clears and the absolute positioning that built the frame,
so replaying it into the fresh xterm a remount just created paints whatever
fragments survive. Main already keeps the thing that does restore a frame: a
real @xterm/headless grid, alt-screen aware, fed unconditionally for SSH. Local
terminals already repaint from it; SSH was the only path that did not.

So this routes an SSH reconnect into the painter that already exists, at the one
expression that chooses model over tail. No new call site, no second lifecycle,
and every existing gate still applies — a null, renderer-sourced or empty
snapshot still degrades to the tail, so it cannot paint blank.

ONLY ON THE ALTERNATE SCREEN, and that is the whole design. The reconnect replay
reaches the renderer without passing through main's model — forwardReattachReplay
and the inline attach replay both bypass onPtyData — so at that moment the model
is stale by exactly the outage. For a full-screen app that trade is right: a tail
cannot rebuild a frame it no longer contains, a grid can, and the SIGWINCH the
restore already sends makes the app redraw the delta. For a scrolling shell it
would be wrong: the tail holds output the model never saw, and preferring the
grid would drop it for good. A park has no such hole, so it keeps using the model
either way.

Derived from the PENDING retry, not directSshRetryAttempt. That also matches the
live binding, which is written at the same tab generation once a reconnect
succeeds and then outlives it — so it stays truthy for every later remount of
that generation. Reading it directly is what made my first attempt fire on mounts
that were not reconnects. Consumed alongside mountFollowsTerminalPark for the
same reason.

Verified live against the reported app: Claude Code restores identical to its
pre-disconnect frame, top restores coherent and live, and a plain shell still
shows output written before the disconnect. 5,470 tests pass across the touched
suites.

Not the whole story, and the remaining half is already written down: the model's
gap exists because the SSH reattach asks for a tail instead of participating in
the checkpointed resume the relay already implements. Close that and this paint
is not merely coherent but exactly correct, for shells too.

* test(ssh): cover a full-screen frame across a reconnect, not just scrollback

The case a byte tail cannot serve, and the one that reached a user twice. A tail
can begin mid-escape and misses the alt-screen enter and absolute positioning
that built the frame, so replaying it paints fragments — which is what a
reconnected Claude Code showed.

Uses top: present on any Linux image, and it repaints on a fixed interval, so a
whole header after the reconnect is unambiguous rather than a timing artifact.
Asserts the header AND the column row, because a tail that lost the frame start
still shows rows.

The spec now covers all three payloads one reconnect has to get right: a shell's
scrollback, a full-screen app's frame, and a tab opened afterwards reaching a
shell that answers.

* ci(e2e): actually run the Docker-SSH specs in the changed-specs lane

"I am surprised this was not caught" has a mechanical answer: these tests do not
run. A spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself when it is unset,
and exactly one place in CI set it — gated on tests/e2e/ephemeral-vm-provisioned-
root.spec.ts being among the changed files. So editing any SSH spec ran it as a
skip and reported green. Eighteen specs reference that variable, including both
reconnect regressions I have been chasing.

Now it is also enabled when any changed spec references the variable, which is
the same grep -l idiom the @headful check two lines below already uses. The
original clause stays: that spec needs Docker without naming the variable, so
replacing it rather than adding to it would have traded one silent skip for
another.

Simulated against the real files — the reconnect spec, the original trigger, a
multi-spec change, a non-Docker SSH spec, and a deleted path — enabling in the
first three, staying off in the last two, and not failing the step on a path that
no longer exists.

* fix(ssh): let the replay veto a stale alternate-screen belief

Adversarial review of the previous commit found a case where it is worse than
the bug it fixes, and it is the exact inverse of what that commit reasoned about.

The model reports alternateScreen from bytes it consumed, and it never consumes
the outage. So if a full-screen app EXITS during the disconnect — an agent
finishes, a command ends, the process dies — the model still says alternate. The
gate then painted a frozen frame of an application that no longer exists and, via
the else-if chain, discarded the replay carrying the shell's real output. Frozen
and wrong beats fragments, which were at least current bytes.

The replay is the only witness to the outage, so it now gets a veto: its last
47/1047/1049 transition, if any, outranks the model's belief. Leaving reset means
the frame is gone and the tail wins; re-entering means the model is right after
all. Same review found the width-mismatch guard drops the alt frame and leaves a
cleared screen for the app to repaint — free for a park with no tail to lose, but
here it meant discarding a usable one for a blank pane, so that degrades too.
Both vetoes are skipped when there is no replay, where they would only trade a
stale frame for an empty one.

Extracted as sshReconnectPaintsFromModel rather than more inline ternary, because
every interesting case is a disagreement between a stale belief and a replay —
awkward to stage end-to-end, trivial to state as a table. 14 unit tests, including
the two that fail against the previous commit. The e2e comment is corrected in the
same spirit: top redraws itself, so it never discriminated the paint source and
should not have claimed to.

Also from the review: the kitty flag stack was left stale on this path, since the
app's pushes during the outage exist only in the replay we discard — scanned now,
after the snapshot so the outage layers on the pre-outage baseline. And the
consume-once comment asserted an invariant that does not exist;
followsDirectSshReconnect is a const captured per connect, bounded by
connectStarted and the gates rather than by the read. Corrected rather than
restructured.

Known and NOT fixed, because it predates this work and is a behavior change of
its own: the model probe is gated on the terminalSshViewParking kill switch, so
turning off view parking also silently disables this repaint. Defaults on.

* docs(ssh): make the parking kill switch's reach over the reconnect repaint deliberate

Review flagged that terminalSshViewParking silently disables the full-screen
reconnect repaint, since both go through the same model probe, and that nothing
said so.

Keeping the coupling and documenting it rather than threading a reason through.
The switch is the kill for painting an SSH pane from main's model at all, and a
reconnect does exactly that; off should restore the relay-tail behavior that
predates the machinery, which is what an escape hatch is for. That matters more
than usual here: this repaint is new and review already found one case where it
was worse than the bug, so a way to turn it off in the field is worth its cost —
a user who disables parking also loses the reconnect repaint.

The alternative is worse than it looks anyway: the probe memo is keyed on ptyId
and shared with the park path, so a per-call reason would be reused by whichever
path created it first.

* docs(ssh): record why the obvious reconnect follow-up does not work

I proposed making the pane-retry path request source recovery the way
reattachKnownPtys does, and argued it was probably client-side routing. Tracing it
says the wiring is indeed trivial and the checkpoint state does survive a drop —
and that the change would still be wrong three ways, one of them harmful.

The relay short-circuits to 'existing' on a same-clientId attach BEFORE it looks
at the recovery argument, and the reconnecting client has already rotated the
delivery onto its id. A failed reattachKnownPtys then deletes the checkpoint on
purpose, so a later pane retry presents checkpointUnavailable, which becomes
restoreRequired and then SSH_SESSION_EXPIRED_ERROR — trading a blank pane with a
tail for a killed session. And the payloads answer different questions anyway:
recovery replays the post-checkpoint delta to keep main's model whole, while the
tail is a screen snapshot for a fresh empty xterm. Even a successful recovery
would put almost nothing in a remounted pane.

Also corrects the argument I had been leaning on hardest. "Old relays ignore
requireReplay, so those users still get blank panes" is false for the SSH relay:
the client deploys its own relay into a version-scoped directory and rejects any
grant whose serverBuildId differs, because client and relay ship in one build.
Mixed versions cannot occur on this channel. The independent-update rule still
governs remote runtime hosts, just not this one — so there is no stranded
population, and the urgency that framing created was imaginary.

What replaces it is a sharper question. Source recovery is gated on
outputFlowControl and on the client presenting a NEW clientId. We have empirical
evidence it does not: the blank-pane bug existed because the relay concluded this
client already held the stream, and the shipped fix works by bypassing that exact
early return. If the id is reused, reattachKnownPtys' recovery hits the same
short-circuit — meaning checkpointed recovery may never have run for SSH
reconnects, and the tail is not a fallback but the only path. Whether that is so
turns on daemon versus stdio-primary relay mode, which I did not verify and which
decides whether the work is "extend recovery" or "recovery has never run here."

* docs(ssh): the root cause — checkpointed recovery never runs on a reconnect

Chasing why the pane-retry path could not request source recovery turned up the
real answer: nothing can. Recovery is dead on every SSH reconnect, and the byte
tail is not a fallback but the only path that has ever run.

Five links, each read rather than inferred. setWrite reuses primaryClient
including its id, so a reconnected client presents the SAME clientId. activate()
tests exactly that at line 99 and returns 'existing' at 108, which makes the
rotateDelivery branch at 118-142 reachable only when the ids differ — never here.
So no sourceRecovery comes back, so finishSourceRecovery fails its
!pendingRecovery guard and abandons, cancelling the delivery and deleting the
checkpoint. The pane retry then opens fresh and takes the tail.

This also explains the blank panes exactly. The relay concluded that this client
already held the stream because, by its own identity rule, it does.

The fix that implies is smaller than anything proposed so far and avoids what
sank the three earlier attempts: bump a transport generation on the client record
in setWrite and compare it alongside clientId, so a reconnect rotates the delivery
instead of matching as 'existing'. Deliveries still outlive their clients and
nothing retires on onClientDetached — the rotation happens on re-attach, which is
what the recovery design already intends. RequestContext, setWrite and the
publication are all relay-internal, and client and relay ship in one build, so
there is no wire change and no compatibility exposure.

Left explicitly unverified: whether rotateDelivery's identity preconditions hold
at that moment, whether outputFlowControl is granted on the reconnected session,
and what a rotation gives the RENDERER — which still remounts an empty xterm and
needs a screen, not a post-checkpoint delta. Recovery keeps main's model whole; it
does not by itself repaint a fresh terminal, so the tail may still be wanted for
the pane even once the model stops going stale.

* ci(e2e): run the Docker-SSH specs when SSH SOURCE changes, not just specs

The earlier fix only helped when a spec file itself changed. Edit pty-connection,
pty-handler or ssh-relay-session and touch no test — which is what every one of
these regressions actually looked like — and the lane still did not run.

pr.yml now maps SSH source paths onto five Docker-backed specs. Five rather than
all fifteen because the rest are covered by unit tests that prove the same source
without paying for a container; that is a deliberate narrowing and this comment is
where it is admitted rather than left implicit. Test files are excluded from
triggering, since they prove themselves.

Simulated against the real paths this PR touches: pty-connection.ts,
pty-handler.ts, ssh-relay-session.ts and ssh-pty-session-reattach.ts all now pull
the SSH specs in, while pty-connection.test.ts, ssh-known-hosts.test.ts,
SshTargetCard.tsx and README.md correctly do not. The gate contract test covers
the mapping: 11 pass.

e2e.yml pays for it — 30 to 45 minutes, because the lane can now build a container
image and run SSH specs serially on top of whatever changed — and installs
openssh-client, which the fixture shells out to and which the lane did not need
back when it never received these specs.

* test(ssh): make the reconnect spec actually run — it now fails on a real bug

It had never executed once. The CI condition that enables Docker-SSH was gated on
an unrelated spec, so this skipped and reported green — and running it for the
first time found two bugs in the spec itself, both of which a typechecked tests/
would have caught instantly.

startDockerSshRelayTarget returns a DockerSshRelayTarget, which has no targetId;
the id comes from connectDockerSshRelayTarget's return value, which the spec
discarded. So every reconnect call passed undefined and the relay answered
'SSH target "undefined" not found'. And openNewTerminalTabInActiveWorkspace takes
the group to open into; called with no argument the new tab lands nowhere.

The third problem was the fixture rather than the spec. The image ships Debian's
/etc/bash.bashrc with the xterm title block commented out and an all-comments
/root/.bashrc, so its shell never emits OSC 0 — which is what Orca derives a tab
title from. The title assertion could not have passed for any shell, healthy or
not, so it was proving nothing. enableDockerSshRelayTargetShellTitle opts a spec
into the title-setting PS1 a real user's shell already has.

IT STILL FAILS, and that is the point: it fails on a PRODUCT bug it was written to
catch. An SSH reconnect destroys the terminal state behind a tab whose local
creation has not yet reached the host. remote-workspace-session-merge.ts:86-89
spreads the host's tab list over the local one for that worktree, so a local tab
missing from the host snapshot has no surviving branch; the upload that would have
put it there is DROPPED rather than deferred inside the 1s suppression window
after a snapshot apply. The tab bar still renders the tab, correctly titled, but
the terminal slice holds one tab and no pane manager exists for the second — so
the user clicks a tab that never paints, with no error and no recovery, while the
process keeps running on the host.

Pre-existing: none of remote-workspace-target-sync.ts,
remote-workspace-session-merge.ts, use-app-session-persistence.ts or
remote-workspace-snapshot-apply.ts is touched by this branch, and nothing in the
merge range touches them either.

Not worked around here. Waiting for the upload would hide it, and a user opening a
tab right after a reconnect has no such signal to wait on.

An earlier version of this message claimed the spec passes. It does not; I had
seen five green runs out of six and generalised from them. Sustained runs are
about three in eleven before the merge and zero in four after.

* chore(e2e): add a typecheck entry point for tests/, unenforced for now

tests/ has never been typechecked. That is how a spec could read target.targetId
off a type with no such field, and call a function without its required argument,
while the suite reported green — the spec was skipping, so nothing ever
disagreed with it.

Pointing tsc at tests/ finds both immediately. It also finds ~198 errors across
~94 files, which is a cleanup project rather than a change to make here, so this
ships as pnpm typecheck:e2e and is deliberately NOT added to the typecheck chain
or to CI. An unenforced script is worth less than a gate, but it is worth more
than nothing: it is runnable, it is discoverable, and the header says plainly
what it is so nobody mistakes it for coverage we have.

runtime-types.ts is the one fix included, because it was actively misleading:
every PaneManagerLike method was optional, so every call site was a
possibly-undefined invocation that TypeScript could not help with. They are real
methods on a real instance. Also widens AppStore to the StoreApi that
window.__store actually is.

* test(ssh): separate the reconnect paint guard from the tab-destruction bug

The paint guard was failing about two runs in three, and after the merge every
run, for a reason that has nothing to do with painting. It staged its full-screen
check in a tab it had opened AFTER a reconnect — which is exactly the tab an
unrelated session-sync bug destroys on the NEXT reconnect. Two independent
failures were riding on one assertion, and the one that fired was not the one the
spec is for.

Running top in the ORIGINAL tab fixes it. That tab predates every reconnect, so it
is in the host snapshot and survives. No assertion changed, none were weakened,
and the new-tab case simply moves after the full-screen case rather than before
it — it still opens its tab after a reconnect, which is the regression it exists
to cover. Five consecutive runs pass at ~13s, against three in eleven before.

The bug itself is not swept up. ssh-reconnect-tab-destruction.spec.ts records it
as a fixme with the mechanism written down: session-merge spreads the host tab
list over the local one, so a local tab missing from the host snapshot has no
surviving branch, and the upload that would have put it there is dropped rather
than deferred inside the 1s window after a snapshot apply. It is worse than a
vanishing tab — the tab bar keeps rendering it, correctly titled, while the
terminal slice has dropped it and no pane manager exists, so the user clicks a
selected tab that never paints, with no error and no recovery, while the process
runs on untouched.

fixme rather than a workaround because waiting for the upload would hide it, and a
user opening a tab right after a reconnect has no such signal to wait on. It is
pre-existing: none of the four files in that path is touched by this branch, and
nothing in the merge range touches them either.

Also lifts openTerminalTab into a shared helper, since both specs need it and the
group argument it must pass is the kind of thing worth stating once.

* fix(ssh): stop a reconnect deleting local state the host has not seen

Reported from a 60-second manual test: reconnect an SSH workspace and the app
drops to the home screen, a second tab running pnpm install is gone entirely, and
one launched agent is listed twice. Three symptoms, one cause.

The snapshot is applied as the whole truth for the reconnecting target. The tab
merge iterates only the host's worktrees, then the result is spread over a gap
where every local tab for that worktree has just been dropped — so a tab created
locally whose upload has not landed has no branch that keeps it. Not a race: it
cannot survive. Same for the pointers, where a snapshot that names no active
worktree nulls activeWorktreeId and activeWorkspaceKey, which is the home screen
while the user's terminals are still running.

So the host is now authoritative for what it knows and not for what it has never
been told. A local tab absent from the snapshot is kept, the worktree union is
used so a snapshot with no entry for it at all cannot erase it, and a null active
worktree only defers to local state when that workspace demonstrably still exists
in the merged result.

Two guards this change had to earn rather than assume. A null activeTabId is NOT
missing information — it is a deliberate deselect that arms the duplicate-tab
repair, and my first attempt defeated it and broke that test; it is honoured
verbatim now. And preserving by tab id alone reintroduces the duplicate agent,
because the host can carry the same session under a new tab id, so the preserve
also checks the remote session id — the identity that survives a tab-id change.

Testing, which is the part that failed here before. Eight tests fail on the
unfixed code and pass on this one, at two levels: the merge decision table, and
the real apply path driven through a store. The end-to-end version of the same
scenario is deliberately NOT the guard and now says so in its header — measured
against unfixed code it only reproduces about one run in three, because the
destruction needs the tab created inside the debounced upload's suppression
window and nothing external can force that. Its earlier green run is exactly why
this shipped.

* fix(ssh): let agent session history recover once the relay is ready

Reported against the adhoc build: a workspace whose editor was loading remote
files perfectly still showed "SSH relay is not ready" and "0 shown · 0 recent" in
the Agent Session History panel, permanently.

That string is what the relay throws before it is ready, which is ordinary at
startup and again for the window a reconnect leaves the session not-ready. The
panel had three refresh triggers — mount, window refocus, and a newly seen agent
session id — and none of them fire when the relay simply becomes ready. So a
transient startup error became a stuck panel next to a workspace that plainly
worked, which is why the report described it as broken while everything else was
fine.

The file explorer already recovers from exactly this, off exactly this signal,
with the rationale written down at use-file-explorer-tree-load-effects.ts: it
loads before SSH providers are registered, so it retries when
sshConnectedGeneration bumps. This panel simply never did. Same idiom, same gate —
only retries when there was a prior error, so a local workspace or one that
already listed fine does not rescan every time some unrelated host connects.

Two tests in the existing suite. The retry one fails on the unfixed code with
"the panel never retried after SSH became ready"; the second pins the gate, since
a retry that fires on every connection bump would turn one bug into a rescan
storm.

* fix(worktrees): name the create route when a raw filesystem error escapes

A worktree create over SSH failed with a bare
"ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'".

That message names nothing. An lstat is Node's LOCAL filesystem, so hitting one
against a path that lives on an SSH host means creation ran a local
implementation for a remote repo — but the user cannot know that, and neither
could I without re-deriving the routing by hand and then failing to reproduce it.

worktrees:create picks between three implementations, and the order matters:
isFolderRepo is consulted BEFORE connectionId, so a folder-kind repo on an SSH
host never reaches the remote path at all. Which route ran, and what the repo
looked like when it was chosen, is the entire diagnosis — and it is knowable
exactly at the throw site, where the decision was just made. So it is stated
there now: route, repo kind, connection id, path, and the original message.

Deliberately additive and deliberately narrow. Only ENOENT/EACCES/EPERM are
rewritten; a git failure, a relay-not-ready, or a validation error already says
what went wrong and burying it under a worse message would be a regression. The
original error is kept as `cause`, so anything matching on `code` or reading the
stack is unaffected.

This does NOT fix the reported failure — I could not reproduce it. On current
code I created a worktree at that exact path, at a second path, with a leftover
directory already present remotely (correctly suffixed -2), and with the SSH
target disconnected (clean actionable error, no ENOENT). What it does is make the
next occurrence identify itself in one screenshot instead of costing another
investigation.

* fix(ssh): recognise a missing path reported by the relay

Creating a worktree over SSH failed with a raw
"ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'".

The path was the one about to be created, so its absence was correct. The caller
asks exactly that question — remotePathExists returns false on ENOENT — and could
not get an answer, so it rethrew at the user instead.

The trace log settles where the error comes from, and it is not where I spent a
long time looking. The stack starts at SshChannelMultiplexer.handleResponse: the
lstat ran on the SSH HOST, and the failure travelled back as JSON-RPC. An lstat in
an ENOENT message is normally Node's local filesystem, which sent me hunting for a
local fs call on a remote path; there is none.

handleResponse rebuilds the error as `new Error(msg.error.message)` and then sets
`code` from `msg.error.code` — the TRANSPORT's numeric JSON-RPC code. Node's
'ENOENT' string code does not survive that, and isENOENT tested only for the
string, so a remote missing path could never be recognised as missing. Every
caller of that predicate asks the same question, so this was wrong for all of
them, not just worktree create.

The message is now consulted as well, matched on Node's full canonical phrase so a
branch name or log line that merely contains the word cannot make an existing path
look absent — that would silently skip a collision check rather than report one.
Fixed on the client because it holds for every relay version, including ones
already deployed; teaching the relay to send the original code would only help
hosts redeployed afterwards.

The two other copies of this predicate, in filesystem-rename-collision and
git-discard-path-safety, are deliberately left alone: both run against a local
filesystem — one inside the relay, one on the desktop — where the string code is
intact and broadening would only add false positives.

Seven tests, three of which fail on the unfixed code: the relay-rebuilt error, the
same error through the IPC wrapper the renderer sees, and one carrying no code at
all.

* revert: drop the worktree-create error-context wrapper

Written to make an unexplained ENOENT self-identifying when creating a worktree
over SSH. The cause is now known and fixed — the error came back from the RELAY
and isENOENT could not recognise it, because the multiplexer rebuilds a remote
error with the transport's numeric code — so the wrapper is scaffolding for a
solved problem.

Worse, its central claim is false. It reported 'the remote (SSH) path failed on a
local filesystem call', and the trace log shows the lstat ran on the SSH host, not
locally. Keeping a message that asserts the wrong thing about the one failure it
was built for is worse than not having it.

183 lines and a rewritten error at the IPC boundary, removed.

* docs(ssh): drop a comment claim about older relays that is not true

The requireReplay comment said the field is optional on the wire so an older relay
ignores it. It cannot happen: the client deploys its own relay into a
version-scoped directory and validateGrant rejects any grant whose serverBuildId
differs, so client and relay are the same build by construction.

The field IS optional, which is why the relay reads it as !== true — that part
stands on its own and needs no story about versions. A comment asserting a
compatibility property the code does not have is worse than no comment, because
the next person plans around it.

* fix(ssh): act on the host key review — three must-fixes and two hazards

M1. A stale record of ours outranked known_hosts, so the remedy we print did not
work. `ssh-keygen -R host` then reconnect leaves known_hosts holding the NEW key
while our store still holds the old one, and the store was consulted first — the
one state that cure produces was the one state we refused. Permanently, since
nothing in the app clears the store. known_hosts now decides a match first, which
concedes nothing: it is the artefact ssh itself obeys, so an attacker who can
rewrite it has already won. Both directions of the precedence are pinned now; the
rotation case fails without this change.

That leaves one rejection known_hosts cannot cure — a host trusted only on first
contact that later rotates its key. "Remove the saved key" named nothing a user
could find, so it now names the store file.

M2. A superseded attempt could put a passphrase prompt in front of a host we had
just refused. The verifier deliberately does not record a rejection for an attempt
nobody is waiting on, so nothing identified it and ssh2's generic handshake error
walked the credential ladder. Guarded on the generation, which catches it whatever
the error turned out to be. Deliberately NOT by rejecting with a cancellation: an
existing test pins that connect() still reports the raw late-startup error, and
that behaviour did not need to change to fix this.

M3. Every unknown host was refused whenever HOME diverges from the passwd home —
devcontainers, `su`, Nix shells, some corporate launchers — because `-F` makes ssh
ignore /etc/ssh/ssh_config and being blind to a site policy was treated as reason
to refuse. Being blind is only a reason to refuse if we cannot go and look, so it
now asks ssh for the system config on its own and takes the stricter of the two.
Only a probe that fails leaves the strict rule standing. Costs one `ssh -G` on the
rare path that already needed -F.

N1. A rejected key's fingerprint was still adopted, and the relay scopes install
locks by it — locks keyed to a host we refused to talk to.

N2. The fallback algorithm list re-introduced the throw its own comment describes.
ssh2 prepends ssh-ed25519 only when a runtime probe succeeds, so on a build where
that probe fails, proposing it makes generateAlgorithmList throw inside
client.connect — and only for hosts we already know. Not reading ssh2's list is a
reason to leave its defaults alone, not to guess: it returns null now and the
caller skips reordering.

1555 tests pass in src/main/ssh.

* docs(ssh): state the merge's real trade instead of claiming it has none

The preserve comment said a genuinely closed tab is never in the local list,
because closing removes it. True for a close on THIS client; false for one closed
on another client sharing the host, where the tab is still local, still absent
from the snapshot, and now kept.

That is a deliberate trade, not an oversight — absence cannot distinguish 'never
uploaded' from 'closed elsewhere', and the outcomes are not symmetric: keeping a
tab a moment too long is recoverable by closing it, deleting a live one with a
process in it is not. But the comment asserted the case could not arise, which is
the kind of claim that gets planned around. Now stated, and pinned by a test so
the next person can see it was chosen rather than missed.

* fix(ssh): stop a newer host key store being silently downgraded

The store writes a version and never read it back. A file from a future Orca would
have had every record dropped by validateRecord — the shape would not match — and
then been REWRITTEN as version 1, so a rollback silently discarded whatever that
version knew. Trust records are user-owned state; losing them costs a
first-contact prompt per host and, worse, re-establishes trust from nothing.

v1 is the only place this can be made safe, because v2 cannot retrofit a v1 that
already clobbers it. A newer file is now left alone: nothing is trusted from it,
and trustHostKey declines to write rather than downgrade. The check sits inside
the snapshot queue so it cannot be separated from the write by another writer, and
declining is not an error the caller fails on — the key still verified, and the
next connect re-derives the same decision from known_hosts.

The test writes a version-99 file and asserts it is byte-identical afterwards; it
fails on the unfixed code with the file rewritten as version 1.

* perf(ssh): skip the reconnect snapshot probe the replay has already ruled out

Every SSH reconnect paid up to the 750ms model-snapshot timeout, including the
ones where the answer was discarded. The gate needs the snapshot's alternate-screen
flag, so the probe looked unavoidable — but one of its two vetoes does not: if the
replay shows the app LEFT the alternate screen, no snapshot can be used whatever it
says.

Asking that first costs a regex over the replay and removes the probe entirely for
that case. It also shrinks the window that matters most: the await sits inside the
structural replay coordinator with live PTY bytes deferred, and the payload can be
superseded while it runs.

Behaviour is unchanged — sshReconnectPaintsFromModel returns false for a null
snapshot exactly as it did for a fetched one it then vetoed, and its tests still
pin both vetoes.

* test(ssh): cover the site host key policy probe

It shipped untested. Three cases, and the third is the one that matters: a system
config naming no policy answers 'ask', not null, because parseSshGOutput fills the
OpenSSH default — and that distinction is exactly what the caller keys on. Null
means 'we could not look', which is the only state that keeps refusing unknown
hosts; a successful read that sets nothing clears the blindness without relaxing
anything, since strictestHostKeyChecking leaves the user's value alone against
'ask'.

I expected null there and was wrong about my own code; the test now records the
behaviour rather than my assumption. Also pins that the probe passes the null
device and terminates its args with -- so a host starting with '-' stays a host.

* fix(ssh): three release blockers from the readiness review

P1-1 was my own fix from the previous round, and it was wrong. I claimed
`ssh -G -F /dev/null` reads the system config while excluding the user's. It does
not: -F excludes /etc/ssh/ssh_config too, which sshGArgsForHost's own comment says
and I quoted before contradicting. Confirmed live against OpenSSH 10.2p1 — plain
`ssh -G` reports the sendenv lines from /etc/ssh/ssh_config, `ssh -F /dev/null -G`
reports none. So the probe returned built-in defaults on every machine, the
fail-closed guard never engaged, and a site-wide StrictHostKeyChecking yes was
silently ignored while we accepted AND durably recorded a key the user's own ssh
refuses. That is worse than the lockout it was meant to fix.

There is no ssh-only way to ask this, so the file is read directly — and the
question asked is deliberately weaker than "what is the policy". Anything
ambiguous (unreadable, an Include that will not resolve, the directive present at
all) answers yes and the caller stays fail-closed. Only a site config that
demonstrably says nothing about host keys clears it, which is the common case that
was being punished. Includes are followed, since macOS and most distros ship
`Include /etc/ssh/ssh_config.d/*` and missing that would read as "no policy" on
nearly every machine that has one. strictestHostKeyChecking goes with it: there is
no separately-read site value left to merge.

P1-2. `ssh -G` prints UserKnownHostsFile unquoted and space-separated even when
the config quoted it — verified the same way. One path containing a space is
therefore indistinguishable from two, and splitting shreds
C:\Users\John Doe\.ssh\known_hosts into fragments that resolve to nothing. Every
fragment misses with ENOENT, which reads as "absent" rather than "unreadable", so
the user appears to know no hosts and a CHANGED key is accepted as first contact.
The filesystem is the only thing that can disambiguate, so it decides: if no
fragment exists but the rejoined path does, it was one path. A list where any
fragment exists is a genuine multi-file config and is left alone.

P1-3. oxlint is a PR gate and this diff failed it on two lines. Both fixed —
including by splitting the replay on ESC rather than matching it, which is
equivalent since every private-mode sequence begins right after one, and respects
no-control-regex instead of suppressing it.

That gate failure is on me twice over: I reported LINT clean repeatedly while
filtering oxlint's output with a grep that could never match its
`path:line:col: error` format. Verification is by exit code now.

5183 tests pass; each fix has a test that fails without it.

* fix(ssh): the readiness review's P2s

P2-1. activeRepoId and activeWorktreeId could describe different workspaces. The
repo followed the host while the worktree came from local state, and it split in
exactly the case the preservation exists for — "the host named no worktree" is
precisely when it can still name a repo. All three active-* fields now derive from
whichever worktree won, rather than each picking a source. The nested ternaries
that hid it are gone.

P2-2. The trust-source reads sit AHEAD of client.connect, and readyTimeout only
covers the handshake — nothing wrapped attemptConnect. A home directory on a
stalled NFS or SMB mount made readFile hang forever, leaving the connection wedged
in `connecting` with no ladder entry and no recovery. Bounded at 5s, reusing the
existing withTimeout helper. The fallback is the one an unreadable file already
produces — evidence withheld, connect as ssh does but record nothing — not the far
worse "no hosts known" that would let a changed key through as first contact.

That helper absorbs rejections into its fallback, so the store's catch had to move
INSIDE the timeout; wrapping the other way silently swallowed the warning that is
the only signal the store is unwired rather than merely slow.

P2-4. doSsh2Connect runs up to five times per attempt as the credential ladder
advances, and each run re-read every known_hosts file, re-read the store, and
re-scanned the system config. Nothing writes those while a handshake is in flight,
so they are read once per attempt — which matters more now that each read can cost
up to 5s. Keyed by connect generation rather than cleared, so a superseded attempt
can never hand its sources to the live one.

P2-3. forgetHostKey was exported, tested and referenced by nothing. The
store-mismatch rejection now names the store file, so the case it was meant to cure
has a cure without it; an exported API nothing can reach is unverified in
production. Removed until D5 ships its UI, and the doc says so.

P2-5 needed no change: the site-policy branch it called dead is reachable again now
that the probe reads the real config.

The design doc drifted from the code in the two places this review checks, and both
are corrected: revocation now propagates for the ordinary rotation because a
known_hosts match is decided first, and the -F blindness is resolved by reading the
file rather than by refusing.

* fix(ssh): rejoin a spaced known_hosts path even beside an ordinary one

The whole-list check only fired when NOTHING in the reported list existed, so a
config naming both a spaced path and an ordinary one kept the spaced one in
fragments — the ordinary path existing was enough to leave it alone. The file the
user actually verified their hosts in then never got read, which is the same
failure the rejoin exists to prevent, just harder to notice.

Longest run first now: the longest sequence of tokens that resolves to a real file
is taken as one path and the scan continues after it, falling back to the single
token when no run resolves. A genuinely absent path is still reported as-is rather
than invented.

The mixed case fails against the previous version.

* test(ssh): pin the site config scanner's edge cases

This control decides whether an unknown host is refused when we cannot see the
site policy, and my first attempt at it was a security regression, so the cases
that decide 'policy present' deserve to be written down rather than assumed.

Seven, and each could have gone the wrong way. A commented-out directive must NOT
read as a policy or the lockout returns for every distro shipping the line
commented. A directive inside a Host or Match block MUST read as one, because no
attempt is made to evaluate whether the block applies — guessing wrong in the
permissive direction is the failure that matters. The equals form counts;
StrictHostKeyCheckingExtended does not. A nested Include is followed, since a
policy one level down is still a policy. An Include cycle terminates and answers
false, which is knowledge rather than doubt: both files were read in full and
neither mentions it.

All seven passed as written, so this pins behaviour rather than fixing it.

* test(ssh): assert tab survival, record the reattach gap rather than flake on it

Running the two SSH e2e specs — which neither review executed — showed the
tab-destruction spec failing on liveness three times out of three. The screenshot
disproved the obvious reading: the marker was on screen, echoed by a live shell.
getTerminalContent resolves the store's active tab id and returns '' when
paneManagers has no entry under it, which is indistinguishable from 'the shell said
nothing', and across a reconnect those two disagree.

Scanning every mounted pane instead fixed the read, and then measured the real
thing: three runs in four. The tab survives every time; the reattach behind it does
not. So the merge fix is real and incomplete — the store keeps the tab, the tab bar
renders it, and the pane sometimes never rebinds, which is the frozen-tab shape the
original report described, one layer down from the deletion that used to cause it.

Asserting that would put a one-in-four flake into the lane built to catch this
class, and a lane nobody trusts is how the original silent-skip failure happened.
So the spec asserts survival, which is deterministic at five runs in five, and the
liveness gap is written down in docs/reference/ssh-reconnect-source-recovery.md
with the first place to look.

* fix(ssh): stop an unreadable host key store from wiping every pinned key

Second readiness pass, checking each of the first pass's ten fixes rather than
taking them on trust. Nine held. This is the one that did not, plus three
fail-open shapes in the site-config scanner that a live OpenSSH disproved.

P1 — the store. loadTrustedHostKeys returns [] for ANY read failure, and
trustHostKey then wrote [...that empty list, newRecord]: one transient EMFILE
followed by one first-contact accept replaced the file with a single record.
Every other host re-TOFUs, and one whose key genuinely changed in between is
accepted as first contact rather than refused — the exact outcome pinning
exists to prevent. It also contradicted the doctrine this PR applies to
known_hosts two files away, where a file that exists and refuses to open is
evidence withheld.

Fixed by classifying one read instead of guessing twice: readStore returns
ok/absent/withheld, the read path flattens withheld to 'nothing trusted' so it
still fails closed, and the write path declines. That subsumes the separate
newer-version probe, so trustHostKey now reads the file once inside the queue
rather than twice. The 'Trusted host key' log moved inside the branch that
actually writes — it was already claiming success on the newer-version path.

P2 — the site-config scanner documents 'doubt wins on every path' and had
three where it did not, each the same shape: a path resolved WRONG still
resolves to something, and a nonexistent Include reads as 'nothing there',
which is indistinguishable from 'no policy'. Verified against OpenSSH 10.2p1:
relative Includes resolve against a fixed dir, not the including file's, so a
directive two deep was missed; ? and [...] are globs it honours; ~ and %-tokens
expand before use. All three now answer doubt.

P2 — credential prompts are gated on the attempt generation in one place
rather than per rung. A superseded attempt is denied without a recorded
decision, so isHostKeyVerificationError reads false and the ladder ran on to
prompt for a passphrase nobody was waiting on.

P2 — resolveKnownHostsFiles is async. Its rejoin existsSync-scans the very
paths the 5s bound protects, and sat outside it as an eagerly-evaluated
argument, so a stalled NFS/SMB mount blocked the whole main process.

Tests fail against the pre-fix code: 2 for the store wipe, 3 for the scanner.

Also: the 4 IME failures I previously reported as pre-existing main breakage
were a stale node_modules — the xterm patch from #14758 was not applied here
(402,643 bytes installed vs 403,181 expected). pnpm install applies it and all
4 pass. The OSC8 and SFTP failures were the same cause.

* fix(ssh): make the merge non-duplicating, and close the last scanner hole

Third readiness pass. Three P2s, all fixed.

The merge one is the one I most wanted a verdict on, and it is real: hostUnknown
filtered against ids the HOST knows and never against ids this same merge had
already emitted, so a tab id local state holds under two worktrees was re-added
under both. Two panes then share one terminalLayoutsByTabId entry and one
remoteSessionIdsByTabId entry — one remote PTY — plus an activeTabId that never
converges, which is the self-retriggering repair loop active-tab-owner-worktree
.ts exists to mitigate (React #185).

This PR does not create that state. It used to DESTROY it, by deleting every
local tab under a replaced worktree, and keeping live panes cost that accidental
cure. So the guarantee is made explicit rather than incidental: the merge now
never emits one tab id twice, whatever it is handed. The active worktree is
walked first so the surviving copy is the one the user is looking at, which is
the owner resolveActiveTabOwnerWorktreeId already prefers — merge and repair now
agree instead of each picking differently.

Scanner: an Include path that is quoted AND contains a space was split before it
was unquoted, so both halves missed and two absent paths read as 'no site
policy'. OpenSSH honours that form -- 10.2p1 applies an Include of a quoted
spaced path -- and it is likelier on Windows. Quote-aware splitting rather than
'any quote is doubt', because answering doubt for an ordinary quoted Include
with no space would reinstate the lockout this scanner exists to avoid. An
unclosed quote is doubt. Unquoted spaces still split, which is also what OpenSSH does.

The reconnect paint gate took the replay and re-scanned it, having already been
scanned by the caller that decides whether to fetch a snapshot at all — two full
splits of up to 100KB per pane per reconnect. It now takes the transition.
hasReplay is passed separately because it cannot be inferred: a replay with no
mode change and no replay at all both give null.

Tests fail against the pre-fix code for the merge and all four scanner shapes.

Correcting my own evidence claim from last round: of the two store tests, only
the wipe one fails pre-fix. The other guards the asymmetry the fix creates and
passes either way — worth keeping, but I should not have counted it.

* fix(ssh): honour every Include quoting form OpenSSH does

Fourth readiness pass. Two findings; one fixed, one deliberately not, with the
evidence for refusing it.

The tokenizer modelled double quotes only. A live 10.2p1 honours single quotes
and backslash-escaped spaces too, and both fell into the same silent fail-open
the double-quote case was raised for: fragments that resolve to nothing, and
'nothing there' is indistinguishable from 'no site policy'.

The escape is limited to a backslash before whitespace, NOT a general one. A
general escape would be catastrophic on the platform this most needs to be right
for: the Windows site config lives at C:\ProgramData\ssh\ssh_config, so it
would eat every separator in an Include beneath it and resolve to nothing --
reintroducing the fail-open it was meant to close. The test for that is
discriminating rather than incidental: it gives the file a literal backslash in
its name, so a swallowed separator resolves elsewhere and fails, where a plain
'expect false' could not tell the two apart. Verified it catches the naive
version, and that the other two catch the old tokenizer.

NOT fixed: the non-duplication guarantee still stops at the worktrees the merge
rewrites. A worktree that is neither replaced nor named by the host is never
walked, so a duplicate straddling that boundary survives.

Extending the guarantee to the assembly point was implemented and REVERTED. Any
rule there has to pick a survivor, and the ones available are wrong during a
worktree-id change -- which is the very thing that produces these duplicates.
Preferring the active worktree keeps the OLD id's copy at the moment a rename
lands, because the active worktree has not moved yet; the new worktree was left
with no tabs and its groups were never created.
remote-workspace-snapshot-duplicate-tab-repair.test.ts caught it, which is the
only reason I know the stronger version was wrong rather than merely bolder. A
surviving duplicate is mitigated by active-tab-owner-worktree.ts; deleting the
tabs of the worktree the user is about to land in is not. The comment now claims
only what holds, and says why it is not stronger.

Also records the exit from the isENOENT message-matching trade in
remote-wire-compatibility.md, where someone touching the relay error path will
be standing.

* fix(ssh): expand Windows OpenSSH's __PROGRAMDATA__ token in known_hosts paths

Captured real 'ssh -G' output from a Windows host rather than reasoning about
it, which is the one thing that could not be inferred from the POSIX format.
Two things came back that the code did not handle correctly, and one of them is
the security failure mode this work exists to prevent.

Native Windows OpenSSH prints the system paths with its own token UNEXPANDED:

  globalknownhostsfile __PROGRAMDATA__\ssh/ssh_known_hosts __PROGRAMDATA__\ssh/ssh_known_hosts2
  userknownhostsfile C:\Users\neil/.ssh/known_hosts C:\Users\neil/.ssh/known_hosts2

Passed through as a literal path, __PROGRAMDATA__\ssh/ssh_known_hosts misses
with ENOENT -- and an absent file is deliberately treated as 'no host is known
there' rather than 'evidence withheld', because that is the normal state. So a
site-managed known_hosts on Windows was silently invisible: every host in it
read as first contact, and one whose key an admin had rotated produced a TOFU
accept where it should have produced a mismatch. Now expanded from
process.env.ProgramData, and left literal when that is unset rather than
guessed -- a wrong path reads as absent, which is the very failure being fixed.

The second finding is reassurance rather than a bug: separators are MIXED within
one path (C:\Users\neil/.ssh/...), which Node's fs accepts on Windows, and a
spaced home prints unquoted exactly as it does on POSIX. So the space-rejoin
design is confirmed against the real format rather than assumed -- its
motivating example, C:\Users\John Doe, splits the way the rejoin expects.

The captured output is pinned as a literal fixture. Parsing and the rejoin are
pure string work, so this covers the input shape honestly off Windows; it does
not pretend to cover the platform's path arithmetic. The expansion test fails
without the fix.

Also confirms C:\ProgramData\ssh is the right site-config directory -- it
exists on the host, empty -- so the scanner is looking in the right place.

* fix(ssh): branch Include backslash handling on platform, both halves measured

Fifth readiness pass found that the previous narrowing traded one fail-open for
another. Both rules are right, on different platforms:

  POSIX 10.2p1:  Include conf\.d/x.conf     resolves as conf.d/x.conf
                 four backslashes needed to survive as one -- argv_split and
                 glob() each consume a level
  Windows:       Include C:\Users\...\x.conf  resolves, separators intact

So a backslash before an ordinary character ESCAPES on POSIX and SEPARATES on
Windows, and either rule applied everywhere fails open on the other platform.
Preserving on POSIX means looking for a path with a literal backslash, missing,
and reading 'no site policy'. Answering doubt on Windows means every absolute
Include is doubt, which is the lockout the scanner exists to avoid.

Now branched. POSIX answers doubt rather than emulating two rounds of glob
escaping for a question this coarse -- a backslash in a POSIX system config path
is vanishingly rare, so fail-closed costs nothing there.

The review offered the Windows half as a reasoned assumption and flagged it as
such. It is now measured on a real Windows host instead: backslash separators
resolve, AND an escaped space still escapes amid them
(C:\Users\neil\sshprobe\sp\ ace\x.conf -> port 2802), which is exactly the rule
implemented. Two other worries were checked and came back unfounded -- a
backslash-space inside EITHER quote is consumed by ssh, and a single quote
inside double quotes is an ordinary character, which the single quote-state
variable already reproduced.

The tokenizer takes the platform as a parameter, so both sides are pinned from
one host. Every expectation in the new oracle came from running a real ssh and
reading what it resolved to, not from reading source or shell convention -- the
tokenizer's whole job is to agree with ssh about which file it would read.

Also narrows an overclaiming comment: the dedupe set is consulted only by the
host-unknown filter, so a duplicate in the HOST's own snapshot still propagates.
Pre-existing and unchanged; the comment now says what the code actually does.

* fix(ssh): only expand __PROGRAMDATA__ when it is a whole path segment

Found by probing the expansion I had just written, rather than by reading it: a
bare startsWith also matches a path that merely BEGINS with those characters, so
__PROGRAMDATA__evil/known_hosts was rewritten to C:\ProgramData\evil\known_hosts
-- a directory the user never named. Same prefix-collision class I checked the
site-config scanner for and then did not check here.

Low reachability, since the token only appears because Windows OpenSSH emitted
it, and it emits it as a whole segment. Fixed because the expansion is one review
pass old and sits in the security path: a rewritten known_hosts path resolves
somewhere unintended, and a path that resolves to nothing reads as 'no host is
known', which is the fail-open this whole line of work has been closing.

Now requires the token to be the entire path or be followed by a separator --
both separators, since the path is Windows-shaped but may be parsed anywhere. The
test fails without the check.

* test(ssh): split the pty provider spawn tests into their own file

CI's static analysis went red on the merge of main: ssh-pty-provider.test.ts
reached 803 counted lines against a maximum of 800. Both sides contributed --
main grew the file and this branch added 7 lines to it -- so neither shows the
violation alone, which is why local lint stayed green until main was merged in.

AGENTS.md forbids disabling max-lines or bumping a per-file limit, and that rule
is right here: the file was doing two jobs. Spawn owns the startup contract --
ingress version, env scrubbing, execution ownership, and the reconnect races --
and is 630 of the 922 lines. It reads as its own unit rather than as an overflow
file, so it moves to ssh-pty-provider-spawn.test.ts and the shared relay stub
moves beside it under a name that says what it is.

Same tests, same count: 713 provider tests pass, and the line total is unchanged
across the two files.

* fix(ssh): remove the dead lint suppressions, and reach Terminal 1 in the restore spec

Two CI failures, both surfaced by this branch rather than caused by it.

Static analysis: the two no-require-imports suppressions on the ssh2 constants
require() are now unused -- main's config no longer reports that rule there --
and the changed-code audit treats a dead directive as an error. Removed; the
audit CI runs passes locally on the merge.

E2E: ssh-cold-activation-restore failed on clicking Terminal 1. This PR is what
routes that spec into the changed-e2e lane at all -- before, the Docker-SSH
specs only ran when someone edited a spec file, which is the gap this branch set
out to close -- so its first run in CI was here, and the failure is pre-existing
rather than new. The trace shows the cause: six restored tabs overflow the strip
at CI's window size and the restore pins it to the END, so Terminal 1 sits
outside the scroll viewport. Playwright's own scroll-into-view loses that race
against the sticky-to-end effect and times out on an element it can see but
never reaches.

The spec's intent is to activate the first tab and prove it remounted, not to
exercise strip scrolling, so it now scrolls the strip to the start first. Not
papering over a product bug: the strip is a native overflow container with
working arrow controls, so a user can reach the tab -- it is Playwright that
cannot drive a moving target.

Six specs pass locally in CI's exact order and worker count.

* test(ssh): press the restored first tab directly instead of waiting for it to hold still

The previous attempt swapped a click for scrollIntoViewIfNeeded and hit the same
30s timeout, which identifies the real cause: not that Terminal 1 is out of view,
but that it never holds STILL. Both APIs wait for the element to stop moving, and
the strip keeps re-laying-out while the relay reconnects behind it -- so both
time out on an element they can see and never settle on.

Driving the pointer directly needs no element to be stable, only to be somewhere
at the moment it is pressed, and the attempt is retried against the store rather
than believed. Activation is deferred to pointerup and suppressed past a drag
threshold, so it has to be a real down/up pair at one position -- a synthetic
click event would not select the tab at all.

Passes twice locally. The previous version also passed locally, so the honest
statement is that the local runs prove the interaction still works, not that they
reproduce CI's instability -- CI is the oracle for that.
2026-08-17 16:40:01 -07:00
Brennan Benson a7f1653415 fix(worktrees): keep retirement tombstones across project and SSH target re-add (#14917)
* fix(worktrees): keep retirement tombstones across project and SSH target re-add

Generated workspace names are retired so a name is never reissued onto a cwd
that still holds another workspace's Claude/Codex history. Two re-add paths
lost that record.

STA-4449 (local): retirement was stored only under `repo.id`. Removing a
project deletes that row and re-adding the same path mints a new id, so the
new repo starts with an empty registry. The on-disk backfill normally
re-seeds local repos, but it cannot recover a name whose only surviving
evidence is a Codex rollout JSONL — those are deliberately not scanned — so a
name spent under Codex with its workspace directory gone came back.

STA-4491 (SSH): the second, path-derived copy embedded the SSH target row id.
Row ids are minted fresh on every re-add, so `ssh:ssh-old:...` became
`ssh:ssh-new:...` and `reassignSshTargetId` migrated other carrier state but
not the retirement namespaces.

Keying the store on the namespace instead of `repo.id` was rejected in
`nestWorkspaces`, `worktreeBasePath` and `repo.path`, so a settings toggle
would orphan every retirement at once. `repo.id` therefore stays primary and
the path-derived namespace stays a mirror — a settings toggle loses the
mirror but keeps the repo row, a re-add loses the repo row but keeps the
mirror, and reads union both.

- Mirror local repos into the namespace too, not just remote ones.
- Key the namespace's host half on the SSH endpoint (host+port+username), the
  thing that actually decides which filesystem a path lands on, instead of the
  target row id. Reads also accept the pre-identity key so an upgrade keeps
  tombstones it already wrote, and `reassignSshTargetId` re-keys the rest.
- Cap the namespace map, which by design outlives the repos that wrote it and
  so has nothing to prune it per repo.

Endpoint identity is extracted from ssh-target-readoption.ts, which already
compared these fields for exactly the same reason, so re-adoption and
retirement cannot drift apart.

* fix(worktrees): copy shared SSH endpoint retirements instead of moving them

An endpoint identity is not owned by the target row that rotates: nothing
dedupes SSH targets by host|port|username, so a second live target can still
resolve to the same host. Moving the bucket stripped that target's tombstones
and reissued a path whose agent history is still on disk.

Row-id identities stay a move — reassignment leaves nothing pointing at them.

* fix(worktrees): carry retirement mirror across in-place SSH endpoint edits

Config sync rewrites host/port/username on the existing target row and a
runtime-owned target takes a fresh address from every provision, both keeping
the row id. No re-adoption runs, so nothing carried the endpoint-keyed mirror
across and it stranded — strictly worse than the pre-change key, which was the
row id and was invariant under these edits.

Also bound the map after a migration: a retained source bucket grows it, so the
cap has to be applied there too, and compare registries by membership rather
than size so an uncompacted destination cannot trade a folded name for a new
one and read as unchanged.

* fix(worktrees): skip retirement migration for on-demand runtime targets

An on-demand VM is discarded between provisions, so its fresh address reaches an
empty filesystem and a reissued name collides with nothing. Migrating there
would spend names against history that no longer exists, and because each
provision mints another address it would add a namespace bucket per run,
evicting the real tombstones of local and ordinary SSH repos.

* fix(worktrees): stop the namespace cap evicting what a migration just wrote

Two defects with one root cause. Assigning to an existing key leaves it in its
original insertion slot, so a merged destination kept the oldest position and the
trim deleted the bucket it had just enriched. Retained source buckets are older
than the destinations a copy appends, so at the cap the trim removed exactly the
sources the copy existed to keep — silently turning it back into a move. The trim
now exempts the keys the migration wrote or deliberately kept.

Also stop on-demand runtime workspaces writing namespace mirrors at all: each
provision reaches a discarded filesystem under a fresh address, so the entry can
never be read back and only spends a capped slot that a local or SSH project
needs. The repo-id row still records the name for the live session.

* fix(worktrees): re-insert migrated namespaces so the cap cannot undo a migration

Exempting keys from the trim protected them for that one call and no other. A
merged destination keeps its original insertion slot, so it sat at the front of
the eviction queue and the next unrelated retirement write dropped it — losing
both the migrated name and the name the destination already held, on a host that
had just been re-added.

Re-insert what the migration writes instead, the same discipline the ordinary
writer already follows, so insertion order reflects use. That also removes the
exemption, which could otherwise leave the map stuck at twice the cap until one
later write evicted the whole excess at once.

Corrects the runtime-gate comment as well: the mirror is unreadable after the
next provision, not immediately, so a remove/re-add inside one provision is a
real if narrow loss.

* fix(worktrees): refresh a retained namespace source even when its merge adds nothing

Replacing the trim exemption with re-insertion narrowed the protection: the
exemption covered every retained source, the re-insertion only covered sources
whose merge actually wrote. A copy whose destination already held the same names
was then neither re-inserted nor exempt, so the migration's own trim evicted the
shared source bucket ahead of hundreds of untouched ones — losing the tombstones
of a live sibling target still on that endpoint, which is what copying exists to
prevent.

A move's destination gets the same treatment: deleting the source makes it the
only remaining copy, so it has been used. Both are order-only and deliberately do
not set the changed flag, keeping an import that moved nothing from scheduling a
save.
2026-08-17 12:23:50 -07:00
Brennan Benson 60805f5c45 fix(agent-status): preserve restored child provenance (#15082)
* fix(agent-status): preserve restored child provenance

* fix(agent-status): preserve restored completion context

* fix(agent-status): retain child boundary across OSC
2026-08-17 11:10:38 -07:00
Jinwoo Hong be07b43a2b fix(orchestration): enforce honest recipient routing (#14964) 2026-08-17 01:50:17 -07:00
Brennan Benson 8ca4ed945e feat(terminal): report execution host and listing scope in terminal list (#14973)
* feat(terminal): report execution host and listing scope in terminal list

`orca terminal list` returned rows with no host identity and no statement
of what the listing covered, so a scoped listing that saw nothing read as
"nothing exists anywhere" — an agent reported a live remote worker dead.

Each row now carries an optional `executionHostId` derived from the PTY id
(SSH and paired-runtime ids embed their owner), and the result carries an
optional `hostScope` naming the hosts covered and the known hosts skipped.
Both are surfaced in `--json` and in the human-readable CLI output, where
an absent field renders as `unknown` rather than `local`.

Both row builders route through one resolver, so the rule lives in one place.

* fix(terminal): preserve unverifiable host scope

* fix(terminal): fail closed on unverifiable hosts

* test(terminal): name unverifiable scope explicitly

* perf(terminal): keep graph hydration host scans narrow

* fix(terminal): reject blank foreign host owners

* fix(terminal): validate inferred inventory hosts

* fix(terminal): preserve paired folder host scope

* fix(terminal): keep inventory host inference typed

* fix(terminal): disclose paired folder hosts
2026-08-16 22:13:03 -07:00
Jinjing 9c4627d1c6 Refactor: split GitHubItemDialog into lifecycle-organized modules (#14931)
* refactor: split GitHubItemDialog.tsx under 400 lines

No intentional behavior change.

* refactor: group github-item-dialog into lifecycle folders

Reorganize the 50 flat files under src/renderer/src/components/
github-item-dialog/ into six lifecycle folders:

  load-item-details/     shared types, both caches, fetch/settle, state badge
  open-dialog/           dialog shell, headers, body, tabs, link copy
  discuss-item/          conversation tab, comments, composer, timeline
  edit-item-fields/      GH edit section, labels, assignees, status
  inspect-pull-request/  combined diff viewer, checks tab
  land-pull-request/     PR actions, merge menu, reviewers

No intentional behavior change. All 50 files moved verbatim; the only
edits are relative-import specifiers (sibling paths plus a depth bump
for ../../../../shared) and the hardcoded module paths in the two
source-boundary tests.

Import graph stays acyclic: zero mutual folder pairs, no file importing
4+ sibling folders, no dest file importing the public barrel, and no
per-folder index barrels.

* refactor: split item references and improve diff-viewer remount logic

- Break down full `GitHubWorkItem` props into discrete `itemId`, `itemNumber`, and
  `itemRepoId` in mutation and action functions to prevent over-memoization of callbacks
  and improve dependency clarity.
- Extract `getPRFilesCombinedDiffSignature()` and use it as a component key to safely
  remount the diff viewer when the PR revision changes, replacing generationRef tracking.
- Add `getKeyedCheckAnnotations()` and `getKeyedCheckJobs()` to generate stable,
  collision-resistant keys for check arrays that may contain duplicates.
- Consolidate interpreter timeouts into a single `SPAWNED_INTERPRETER_TIMEOUT_MS` constant
  and apply it via describe options rather than per-test values.

* refactor: improve github-item-dialog repo context and i18n coverage

- Add repoId prop to ConversationTab for explicit repo context override
- Internationalize UI strings in diff viewer and PR action components
- Improve error handling with cache rollback and guard cleanup on sync failure
- Enhance cache key validation for cross-window invalidation by repoPath
- Add repository access validation before rendering diff viewer
- Fix cross-platform issues: skip symlink test on Windows, normalize CRLF in test assertions

* Refactor check button i18n key and update text

- Replace hash-based key with semantic name for maintainability
- Change button label to "Open in browser" for broader context
2026-08-16 16:44:48 -07:00
Neil 9367169888 refactor(tests): split every oversized test file off the max-lines suppression list (#14728)
* refactor(tests): split oversized test files off the max-lines suppression list

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A real renderer never loses `window`, so deleting it was the artificial part.
Swap in an inert proxy whose properties resolve to callables and whose calls
resolve to undefined, making a late `window.api.pty.*` call a harmless no-op.
The next test replaces it wholesale via installTerminalTestGlobals(), and no test
asserts that `window` is absent.
2026-08-15 00:54:20 -07:00
NeilandOrca 2100fb2553 fix(runtime): cap remote git.diff and file previews at the transport budget (#14160)
* fix(runtime): cap remote git.diff and file previews at the transport budget

A remote or mobile user who opens the diff of a large image loses their whole
WebSocket, not just that request: the E2EE channel closes with 1013 when a reply
exceeds the 4 MiB outbound envelope. Two producers can exceed it unaided.

git.diff/branchDiff/commitDiff cap text with MAX_RENDERED_DIFF_COMBINED_CHARACTERS
(6M chars) -- a *renderer* budget that sits above the transport limit -- and return
base64 for previewable binaries bounded only by MAX_GIT_SHOW_BYTES, so a 10 MiB PNG
changed in place is ~26.7 MiB in one envelope. files.readPreview inlines base64 up
to 10 MiB, and mobile calls it for every image tab.

Both now measure against a budget derived from the outbound limit. The check sits in
orca-runtime-git.ts, downstream of the dedupe and of both the SSH-provider and local
branches, so a payload forwarded verbatim by an old relay is covered by the same code
and src/relay needs no change. Local and in-process callers pass no budget and keep
full fidelity.

Measuring raw bytes would not work, which is the whole reason this needs a module.
JSON escaping turns one control byte into six (\u00XX), and binary-buffer.ts sniffs
only for NUL in the first 8 KiB -- so a NUL-free file of 0x01-0x1f bytes is classified
as *text*, would pass a raw-byte cap, and would then blow the envelope. The budget is
escape-aware, with a three-branch fast path that keeps normal diffs at two native
byteLength calls and scans only the ambiguous band.

The SSH branch of readFileExplorerPreview had the same raw-vs-escaped gap: its stat
gate sizes base64 binaries, but text crossed unbounded. It now honours the same
decoded-text limit the local branch already enforced.

No wire change: GitDiffResult is untouched -- no third kind, no new field. Old clients
see an error for one request instead of a dropped connection. diff_too_large joins the
structured passthrough codes and lands on an existing error arm in both mobile
consumers and the desktop remote path; file_too_large was already handled on both.

Instruments the 1013 close, which nothing measured before, so the incidence this cap
is meant to drive to zero is finally observable. `emitter` separates a producer size
bug from a wedged link.

Known regression: remote image previews between ~3.096 and ~3.146 MB now return
file_too_large. They only intermittently worked before -- above ~3.0 MB they killed
the socket -- so this trades intermittent connection loss for a consistent error.

Test: 10281 passed in src/main/runtime + src/shared + src/main/git; mobile 3427
passed. Each of the six budget-enforcement sites is independently mutation-killed.
Escaping fixtures cover newline-dense, control-char, CJK, lone-surrogate and base64
content against native JSON.stringify. tsc clean for node, web and cli; oxlint clean.

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

* fix(runtime): harden remote reply transport budgets

* test(runtime): cover desktop remote preview budgets

* test(runtime): close telemetry review gaps

* chore(shared): repoint budget imports after the shared/types barrel removal

Upstream #14447 dropped the shared/types barrel; GitDiffResult now lives in
git-diff-compare-types and GlobalSettings in global-settings-types.

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

* fix(ssh): surface an over-cap preview read as file_too_large

The stream reader aborts an over-cap read with StreamProtocolError, whose numeric
code falls through mapRuntimeError to a generic runtime_error carrying the raw
"Reported totalSize N exceeds client cap M" string. Neither preview client
recognizes that: runtime-file-client.ts and mobile-file-preview-response.ts both
key on file_too_large. It also made the two file_too_large guards directly below
the read unreachable on the streaming path.

Gives the cap its own error type so the caller can translate it, keeping the
bandwidth saving the cap exists for. A genuine protocol fault still propagates
unmasked.

Found by the readiness review. Mutation-verified: removing the translation fails
exactly the new test.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-14 00:24:57 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00
Neil eb22e497bb Revert "fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes" (#14395) 2026-08-13 18:11:33 -07:00
Neil 6a0c8fa541 fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes (#14384)
* Reapply #13326 and #13928 (un-revert #14361)

Restores the SSH reattach-identity and daemon-occupancy fixes. Reverting them
reintroduced their P0s, filed as STA-4224, STA-4225, STA-4227, STA-4230,
STA-4232, STA-4233 and STA-4234 against #14361.

The tab loss that motivated the revert is fixed in the commits that follow, so
this reapplication is not a straight redo.

* fix(relay): stop the fallback attach fence refusing a pane that moved tabs

The primary fence was moved to the shell's own incarnation precisely because
paneKey/tabId froze the pane's LOCATION at spawn and refused panes that had
merely moved. The fallback that older clients fall into kept the old rule, so
the correction never reached it — the same 'the rule exists, but this path does
not ask it' leak this work has hit repeatedly.

A refusal here is not recoverable: an identity mismatch never grounds a respawn,
so the pane keeps a live shell it can no longer reach and renders blank.

Narrowed to paneKey, which is the identity; the tab is a location. Restoring the
tabId comparison reddens the new test.
2026-08-13 16:45:24 -07:00
Neil 11cd2b4310 revert(ssh): back out #13326 and #13928 — reconnect loses every tab (#14361)
* Revert "fix(daemon): stop killing live coding agents when the daemon can't report its sessions (#13928)"

This reverts commit 2e8cf589de.

Reverted together with #13326: the 1.4.182-daily.202608131439 build carrying
both loses every tab on an SSH disconnect/reconnect cycle. Reverting first so
main stays releasable and the P0 fixes in the wild remain cherry-pickable,
rather than fixing forward on a shipped regression.

* Revert "fix(ssh): stop SSH reconnect from multiplying terminals and resuming agents twice (STA-3077)" (#13326)

This reverts commit 3ab8b6a117.

Reported on 1.4.182-daily.202608131439: connect to an SSH worktree, disconnect
the host from the hosts popup, reconnect — every tab is gone. That is worse than
the behaviour this PR set out to fix, where most tabs were retained.

Reverting rather than fixing forward, so main stays releasable and the P0 fixes
already out in the wild stay cherry-pickable. STA-3077 stays open.
2026-08-13 14:54:29 -07:00
Jinjing e7b85266f5 Add golden E2E tests for fresh terminal and shell commands (#14302)
* test(e2e): add golden tests for fresh terminal and shell commands

Adds regression tests for terminal initialization in fresh profiles and shell command execution to the golden test suite, integrated across Linux, macOS, and Windows CI.

* test(e2e): bracket shell command output between markers

The echoed command can wrap or be clipped by the buffer tail. Bracket output between begin and end markers to reliably identify real output, and strip ANSI escape sequences that interfere with parsing.
2026-08-13 09:31:20 -07:00
NeilandOrca 3ab8b6a117 fix(ssh): stop SSH reconnect from multiplying terminals and resuming agents twice (STA-3077) (#13326)
* fix(ssh): stop reconnect from grafting panes and stacking remote leases

Reconnecting an SSH-backed workspace added terminal panes the user never
opened, and the remote host accumulated shells nobody was using — one
report went from 2 to 19 to 20 relay PTYs across three reconnects
(STA-3077).

Two root causes, both in the store.

Reattach could create UI. `persistPtyBinding` has four creating branches
— mint a tab, mint a root leaf, split the root and graft a leaf, mint a
layout. All four are load-bearing for `pty:spawn`, which can beat the
renderer's debounced layout writer, but none of them is appropriate on
reattach, where the pane either already exists or is gone for good. Add
`mayCreate`, defaulting true so the spawn path is untouched; every
creating branch already sets `terminalMembershipChanged`, so refusing is
a check rather than a new code path.

Lease identity had no pane key. `upsertSshRemotePtyLease` matched on
`(targetId, ptyId)` alone, so a pane that re-leased under a new relay id
left its predecessor live with nothing to retire it, and the next
reattach fanned out over both. One pane now keeps at most one live
lease. Superseded leases are marked `expired` rather than terminated:
losing a lease is not proof the shell died, so the remote process is
deliberately left running.

Tests assert observable behavior rather than mechanism, so they stay
valid under any implementation that fixes this.

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

* docs(terminal): record the terminal session behavior contract

Properties stated as observable behavior rather than mechanism, so an
oracle written against them survives a change of implementation.

Records the weaker, correct form of the timer rule — a timer may never
be the sole cause of a destructive action — because recovery budgets and
scratch-file age gates are correct code that an absolute ban would
condemn. Also notes which mechanisms are deliberately not required, so
each has to earn its place rather than arrive with an architecture.

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

* fix(ssh): heal duplicate pane leases that predate pane-keyed supersession

Pane-keyed supersession stops new duplicates, but it does nothing for
installs that already carry the ones STA-3077 accumulated — the report
behind this reached 20 live leases across a handful of panes, and every
reconnect fanned out over all of them.

Retire the stale duplicates once per reattach pass, keeping the newest
lease for each pane under a total order so two hosts resolve a tie the
same way. As with supersession, retired leases are marked `expired`
rather than terminated: their remote shells are deliberately left
running, because a lease we chose not to revive is not evidence the
shell died.

The relay-session store stubs gain the new method. Note the gap this
leaves open: those shells keep running and are no longer reachable from
the app, so the "accumulates unused shells" half of the report needs a
visible recovery surface rather than a silent kill.

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

* fix(terminal): stop respawning a shell that is still running

A pane that failed to reattach spawned a fresh shell. Because the
restored session id came along, the replacement resumed the same agent
session, and two processes appended to one transcript — reported
repeatedly, up to five concurrent resumes of a single session.

Two defects fed it.

The relay reported a source that merely needed re-establishing as
`SSH_SESSION_EXPIRED`. The shell was still running; only its output
source was gone. Give that outcome its own error so it stops reading as
"the session no longer exists".

The reattach failure handler then treated every error as proof of death.
It checked for expiry and, in the else branch, took the identical
action — so the check bought nothing and a transport fault, a timed-out
call, or a wedged relay all respawned. Respawn now requires proof: an
explicit host expiry or a not-found PTY. Anything else, including an
error we have never seen before, is unresolved, leaves the shell
running, and keeps the binding for a later reattach.

Two existing tests asserted the old behavior. One threw a bare error as
scaffolding to reach the spawn-adoption door; it now throws proof, which
is what it meant. The other pinned the expiry mapping itself, and now
asserts the outcome fails closed *without* being reported as expiry.

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

* docs(terminal): record what makes a retention bound safe

Shortening a grace period is the wrong lever. Measuring process time and
gating reclamation on an independent observation are what make one safe,
and they are what deployed systems actually do.

Also records that lifecycle belongs in the attach reply rather than a
delivered event — that is what removes the need for a durable per-consumer
cursor to guarantee an exit is never lost.

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

* test(terminal): assert the empty-failure case without an empty Error

A thrown empty value exercises the same property — a failure carrying no
usable message is not proof the session is gone — and does not trip the
empty-error-message lint.

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

* fix(ssh): let the durable pane binding outrank recency when retiring leases

Choosing the newest lease for a pane is wrong whenever a newer lease
exists that no pane is bound to: it retires the lease the pane is
actually attached to, detaching a live terminal instead of healing it.

Two changes. Arbitration now prefers the lease matching the pane's
durable binding, across both the SSH-target and local partitions,
falling back to recency only when no binding names either candidate.

And supersession at upsert time now defers rather than expiring a bound
predecessor. When a lease arrives for a pane that is still bound to a
different PTY, the binding has not caught up yet, so both stay live and
reattach arbitrates once the binding is available.

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

* fix(ssh): roll back a lease retirement whose durable write fails

`flush()` logs and swallows write errors, so a failed write left these
leases retired in memory while disk still called them attached — and the
pane bindings scrubbed alongside them stayed scrubbed. Use `flushOrThrow`
and restore both the lease states and the affected session partitions
when it throws, reporting nothing retired.

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

* test(ssh): prove pane and remote PTY cardinality across reconnects

Counts the shells the relay actually hosts, on the container, rather
than inferring them from app state — that is the census the report was
based on. Asserts the PIDs are unchanged, not merely the count, so a
kill-and-respawn cannot pass.

Every pane streams before the transport is severed: an idle pane sends
no recovery checkpoint, so only a live source comes back needing
re-establishment, which is the outcome that used to read as expiry.

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

* fix(ssh): actually pass mayCreate:false from the reattach binding write

The `mayCreate` guard was correct and had no production caller, so the
reattach path still went through the creating branches and grafted panes
back. `restoreReattachedPtyRuntime` is that call site — RC3 in the
original diagnosis — and it now refuses to create.

Binding moves ahead of runtime registration, because registering first
would surface a pane the user never opened before the refusal landed. A
refusal leaves the remote shell running and reattachable; a *thrown*
write stays unknown and still registers, so a failed disk write cannot
detach a live pane.

Adds an oracle over the call site itself. The store-level tests all
passed while the fix was inert, because they called the store directly —
only pinning the wiring catches that.

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

* fix(terminal): apply the respawn-requires-proof rule to both reattach paths

connectPanePty has two near-verbatim reattach blocks — one keyed on the
deferred SSH session, one on the restored session — and only the second
was fixed. The first still checked for expiry and then respawned
unconditionally anyway, so a transport fault there resumed the same agent
session a second time.

Also keep the wire token out of the pane. The main-process bridge only
special-cases expiry, so a source-restore failure crossed IPC as raw
`SSH_SOURCE_RESTORE_REQUIRED: <id>` text and surfaced to the user. It
correctly does not respawn; it just should not read like that.

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

* test(ssh): state plainly that the reconnect spec is a forward guard

It was run against an unfixed tree and passed, so it does not prove the
STA-3077 fixes and should not be read as if it does. A clean severed
transport does not reproduce the field conditions — accumulated duplicate
leases, or a source returning needing re-establishment.

It keeps its place as a forward guard: it counts the shells the relay
actually hosts and pins their PIDs, so a later change that grafts a pane
or respawns a shell fails here.

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

* docs(terminal): record that a guard must be pinned at its call site

A refusal that exists and is never passed is indistinguishable from no
refusal, and store-level tests cannot tell the difference — they call the
store directly. Learned from `mayCreate`, which was correct and had no
production caller for several commits.

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

* fix(ssh): park one PTY's exhausted delivery recovery instead of dropping the channel

A per-PTY recovery budget running out disposed the whole relay channel,
so one PTY that could not re-prove its delivery aborted every in-flight
filesystem and git request on that host and stalled every sibling pane.
A retry count is not proof of anything, and it certainly is not proof
about the other sessions sharing the channel.

Exhaustion now parks that PTY's delivery. The remote shell keeps
running, its lease stands, and the next relay open reattaches it with a
fresh delivery generation — the parked state is cleared on teardown and
the generation changes on reconnect, so a reconnect recovers it.

The consecutive-attempt ceiling goes away entirely; the per-generation
one is what bounds the retry cost, and the second ceiling only existed
to reach the channel drop sooner.

Tradeoff worth stating: the failing pane used to self-heal within
seconds because the forced reconnect wiped all rejection state, and it
now stays frozen until the next relay open. That is a worse outcome for
that one pane and a much better one for every other session on the host,
and reconnecting is user-reachable.

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

* fix(pty): let liveness say unknown instead of forcing it to say dead

`IPtyProvider.hasPty` returned a boolean, so a provider whose inventory
was empty for reasons that have nothing to do with the session — socket
down, cache never hydrated, provider generation just constructed — had no
way to say so and answered "absent". Its own siblings already knew
better: `probePtyLiveness` and the runtime's `PtyController.hasPty` were
both already `boolean | null`, with consumers branching on null
correctly. The lie was injected at exactly one interface.

Now three-valued, and each provider answers unknown where it cannot
prove absence: the daemon adapter off-socket, the SSH provider before a
completed listing, the router when any adapter cannot answer, and the
degraded provider rather than fabricating a verdict. `terminal_gone`
requires unanimous proven absence.

Also fixes a real cold-start bug this surfaced: `pty:hasPty` never
awaited the daemon-swap startup promise, though the sibling
`probePtyLiveness` bridge already did, so before the swap the local
provider answered an authoritative false for every daemon-owned id.

Net +27 production lines. The plan behind this predicted -92 on the
strength of deleting the renderer's dead-session reconcile path; that
code is live (`pty-connection.ts` imports it), so nothing was deleted.
Expressing a third value where there were two costs lines, and a
deletion that is not real is not worth manufacturing.

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

* docs(terminal): track the terminal-session correctness handoff package

The package was untracked under a gitignored `docs/**`, with the
un-ignore rules living only in an uncommitted .gitignore edit — a single
`git clean -xdf` would have destroyed the authoritative plan.

The 814-path construction snapshot is now pushed as
`nwparker/react185-authority-snapshot` too; it had no remote ref.

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

* test(ssh): make the reconnect settle window actually wait

The settle poll reused a matcher the assertion 15 lines above had already
satisfied, and Playwright's poll engine probes immediately and returns as
soon as the matcher passes — so it observed the same state twice and
elapsed 0ms. A shell grafted a second or two after reattach reported
ready slipped through into the next cycle.

Reviewer was right on #13111. Test-only; no production change.

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

* test(ssh): census both durable session partitions on reconnect

Adds a second reconnect scenario and a helper that reads pane records
from the local partition as well as the ssh host partition. That split
matters: the reattach binding call passes no hostId, so a grafted pane
lands in the LOCAL partition and an oracle reading only the host
partition passes whether or not the guard is present.

Both tests remain forward guards. The second one was reported as
discriminating and did not reproduce: with `mayCreate: false` removed
from the call site and the app rebuilt, both still passed. Its induction
races `pty:kill` against a severed transport, so when the kill lands the
lease is cleaned up and there is nothing left to graft. The handoff
README is corrected to say so rather than claim a journey.

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

* docs(terminal): record the user decision relaxing G6

G6 becomes minimise-and-justify rather than strictly net-negative. The
deletion budget the plan assumed does not exist: an entrypoint-rooted
import graph found 51 of 53 candidate files reachable and instantiated
on live paths, leaving 263 deletable LOC against roughly +1,021 to
offset. Correctness may still not be traded for line count.

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

* test(terminal): add discriminating oracles for restart, daemon, skew and namespaces

Six parallel streams, each required to fail with its guard removed rather
than merely pass.

Local restart proves the OS process itself survives, by reading
`ps -o lstart=` for the shell's own pid. That matters: with the quit path
made destructive, the tab, leaf and pty ids all came back byte-identical
while the shell underneath was a new process — every existing restart
spec would have stayed green. Two separate guards were removed to redden
it, and the second reddens only the stale-operation case.

Daemon restart discriminates by reverting three-valued `hasPty`; version
skew now covers publication semantics and confirms the new
`SSH_SOURCE_RESTORE_REQUIRED` token mutates nothing on an old client;
two-host isolation censuses both containers.

Deletes `src/relay/pty-source-replay-index.ts` — 201 production lines
with no importer outside its own test, verified against an
entrypoint-rooted import graph rather than a name grep.

Five namespace tests are skipped, not passing: they reproduce a defect
still live on main where folder-workspace ids compare equal with the
instance suffix stripped. PR #12474 fixes it; they are its oracle.

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

* test(ssh): induce the reattach graft deterministically instead of racing a kill

The previous induction closed a pane while the transport was severed and
relied on `pty:kill` FAILING so the lease outlived the pane record. It
does not fail: with the provider already torn down, `pty:kill` takes its
tombstone branch and marks the lease terminated, and `reattachKnownPtys`
filters terminated leases out of the fan-out — so the reconnect never
visited the PTY the test was about. It passed on both trees.

Seed the precondition instead. Spawn a real remote PTY on a leaf that
never becomes a pane, then roll the host partition back to its pre-spawn
snapshot, leaving a live lease and a live remote shell that no durable
pane owns. No failure races a success.

Adds a vacuity guard that is independent of the tree under test: the
lease's own `lastAttachedAt` must advance, proving the fan-out actually
visited this lease before the pane census is trusted.

Verified on this machine under an isolated TMPDIR, since the e2e
harness keys its seeded-repo pointer on a machine-global tmpdir path:
guard present passes, guard removed fails with the phantom leaf grafted
into the local partition, guard restored passes.

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

* docs(terminal): propose one authoritative binding identity

Every defect this program has touched is the same defect: identity
compared with the wrong key, or not compared at all. Lease keyed without
the pane, reattach using a creating write, folder-workspace ids compared
with the instance suffix stripped, local mutating IPC carrying only an
id, a live shell classified as expired, liveness unable to say unknown.

Proposal: one branded binding type built from fields that already exist
and are already persisted, constructible only from an authoritative
source, carried by mutating operations, compared by one shared function.
Makes a wrong-key comparison a type error rather than the next incident.

Under adversarial review, including against the open issue corpus.
Not accepted.

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

* fix(pty): refuse mutating operations aimed at a superseded PTY

`pty:write`, `pty:writeAccepted` and `pty:resize` accepted any id. The
renderer queues input, so a keystroke buffered before a reattach landed
on whatever PTY had since taken the pane — and a resize reshaped the
successor's shell.

Main already tracks `ptyPaneKey` and `paneKeyPtyId` in lock-step, so
their disagreement is proof the caller's id was superseded. No wire
change, no renderer change, nothing added to the input payload.

An id with no recorded pane stays permitted: unowned and orphaned PTYs
are unknown, not stale, and unknown never authorizes refusing an explicit
operation. That is also what keeps orphan cleanup working — those ids
have no pane by construction.

The tests pin the CALL SITES, not the predicate. A capability that exists
and is never called is indistinguishable from no capability, which is
exactly how `mayCreate` sat inert here for several commits with every
test green.

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

* fix(pty): fence signals at a superseded PTY, and pin why kill is exempt

A signal means "interrupt my pane", so delivering one to a PTY the pane
has already replaced is a misdirected interrupt. Fence it with the same
lock-step proof used for write and resize.

`pty:kill` stays deliberately unfenced and a test now pins that: a
superseded PTY is orphaned, and reclaiming it is exactly what the
orphan-cleanup callers ask for. Refusing there would break the operation
that reclaims leaked shells — the opposite of the intent.

The fence sits at the IPC boundary, above `tryGetProviderForPty`, so it
covers local, daemon and SSH rather than the local path alone.

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

* test(terminal): poll the pane binding read so a slower host cannot flake it

`readPaneBinding` took a single unpolled read of a DOM dataset attribute
immediately after a renderer reload, while its sibling helper polls the
same data for 15s. On a native Linux host both tests failed every run
with 'No bound terminal pane is mounted' while the app was demonstrably
healthy — the screenshot showed the terminal restored with a live prompt
and the boot PID echoed.

The assertion is unchanged; it is only awaited. Nothing is weakened.

Found by running this spec on native Linux rather than assuming macOS
behaviour generalises.

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

* test(terminal): make the restart identity spec run on Windows too

Both probes were POSIX-only and unconditional: `echo ...=\$\$` for the
shell's own pid, and `ps -o lstart=` for its start time. Running the spec
on a real Windows host proved it dies before reaching either guard, so
Journey 1's Windows half was unprovable rather than merely unproven.

PowerShell exposes the same two facts as `$PID` and `Get-Process`
StartTime. The start time still matters on both platforms for the same
reason: a PID alone cannot separate a survivor from a reused number.

Still green on macOS. The Windows path is written from the host probe and
has not itself been executed end to end — that is the next thing to run
there, not a claim being made here.

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

* docs(terminal): record the fence's real gap and what peer designs taught

Marks the client-constructed binding proposal as rejected with the three
false claims that sank it, and records what shipped instead.

States the shipped fence's actual limitation rather than leaving it
implied: it compares a binding, not an incarnation, so a respawn under a
reused ptyId passes. The obvious remedy is wrong here — the agent-create
id is deterministic by design so a replayed create stays idempotent, and
randomising it would trade this narrow gap for a duplicate-spawn bug.

Also records the ranked lessons from four comparable agent IDEs, chiefly
that a typed end-reason at end time is what stops a user quit from
looking like a resume candidate.

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

* docs(terminal): promote Journey 1 to proven on all three platforms

The oracle now runs natively on macOS, Linux and Windows, and its
discrimination was watched on each: a mutation reddens it, a restore
greens it. On Linux and Windows both mutations were run, and the second
reddens only the stale-operation test — so the journey's two clauses are
proved independently rather than jointly.

Windows is the new evidence. The PowerShell branches added blind at
ebffb85a848 executed correctly on their first run: `$PID` expanded to
real integers, which also proves the pane shell there is PowerShell-family
rather than Git Bash, and `Get-Process StartTime` returned kernel start
times 5.4s apart — so a recycled pid could not have passed as a survivor.

First journey promoted in this program. The other twelve are unchanged,
and the residual limit on "every stale exact operation" is recorded
rather than glossed.

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

* test(terminal): add discriminating oracles for the daemon, skew and multi-host journeys

Daemon: replaces a spec that modelled only a client restart and never
crossed the daemon boundary, whose successor generation owned nothing so
"the live successor is neither killed nor replaced" was vacuous. The PTY
leader is now a real login shell reporting `$$` back through the
production write path, resolved to a kernel start time. Two mutations
each redden exactly one of the three clauses, on macOS and Linux:
reverting three-valued `hasPty` reddens only the unknown-not-dead
clause; widening the sole-provider fallback reddens only the stale
generation clause.

Skew: reverting the restore-required publication to expiry reddens 4 of
5 new tests while the legacy control stays green — the regression this
branch fixed is now caught if reintroduced.

Multi-host: restoring `mux.dispose('connection_lost')` reddens sibling
isolation on one host. It does NOT redden across hosts, and that is
recorded rather than glossed: a mux belongs to one relay session per
target, so its dispose cannot cross a host boundary. Journey 4's
cross-host clause rests on isolation-by-construction, not on a mutation.

No production code changes.

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

* docs(terminal): record journey evidence that falls short of promotion

Four journeys now have discriminating oracles but none meets its full
stated scope, and each shortfall is named rather than rounded up.

Journey 2 is one WSL run from promotion. Journey 12's tests are
in-process, so they do not close the live-skew gap the original ledger
named. Journey 4's cross-host clause cannot be proven by mutation at all
— a mux is per target, so its dispose cannot cross hosts, and the
cross-host test stayed green under the mutation that reddens siblings.
Journey 13 measured one dimension of ten, on lifted predicates rather
than through real IPC.

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

* docs(terminal): promote Journey 2 to proven on macOS, Linux and physical WSL

The oracle runs on every environment the journey names, and is
clause-selective on all three: reverting three-valued `hasPty` reddens
only the unknown-not-dead clause, and widening the sole-provider fallback
reddens only the stale-generation clause.

Selectivity in WSL was established rather than assumed. The spec runs
serially, so a red first test reports the others as "did not run" — they
were re-run alone under the same mutation and stayed green.

Also records that an Orca WSL-mode terminal now starts on that host at
all, which it could not before: the distro had no provisioned default
Unix user, so every interactive launch blocked on first-run setup.

One diagnosis from the WSL run is corrected here rather than repeated:
the unrelated `local-pty-shell-ready` failure was attributed to bash
5.3.9, but macOS runs the same bash version and passes 67/67. The trigger
is environmental to that distro, and the underlying defect is that the
spec pins an absolute count of OSC markers it does not own.

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

* docs(terminal): correct the WSL provider-suite diagnosis

The WSL run blamed bash 5.3.9 for the unrelated
`local-pty-shell-ready` failure. macOS runs the same bash version and
passes 67/67, so the version is not the cause — the trigger is
environmental to that distro, and the underlying defect is that the spec
asserts an absolute count of OSC markers it does not own.

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

* test(runtime): unskip the workspace-namespace oracles now their fix has merged

These five reproduced a defect that was live on main: folder-workspace
ids were compared with the instance suffix stripped, so two workspaces
sharing a directory read as the same namespace. They were committed
skipped, pointing at the PR that fixes it.

That PR is merged, and they pass. Verified they still bite: restoring the
suffix-stripping comparison reddens exactly these five and leaves the
other four green.

An oracle written before its fix, held skipped, and confirmed against the
fix after the merge — rather than deleted and rewritten from the answer.

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

* test(ssh): add MaxSessions, lazy-discovery and paired-skew oracles

Three journeys attempted; none promoted, and the reasons are recorded in
the ledger rather than rounded up.

MaxSessions=1 against real OpenSSH, with the cap read back from `sshd -T`
rather than assumed, and remote pids read on the container two
independent ways that must agree, each carrying its kernel start time.
Two disjoint mutations discriminate — one reddens only the reconnect
clause, the other only the two restart clauses. But the disconnect clause
is a forward guard: four separate guard removals left it green, so
nothing shipped is load-bearing for it.

Lazy discovery samples sshd's own accept log and live session census
across a 22s window with the in-use host as a positive control. No
mutation reddens its third clause alone — the real cross-host lease
scoping is load-bearing, but removing it breaks the sibling host during
setup, so the failure carries no clause information.

The paired-runtime skew spec pairs two real processes at different
versions and refuses to run rather than degrade into a same-version
pairing that would look green and prove nothing.

No production code changes.

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

* docs(terminal): record why the duplicate-resume fix was not built

I recommended adding a typed end-reason so a user quit stops looking like
a resume candidate, then went to implement it and stopped.

`SleepingAgentSessionRecord` already carries three fields that each exist
to stop something resuming that should not have — `origin`,
`restoreOnTabOpenOnly`, and `automaticResumeBlockedBy` — each traceable
to its own incident, consulted at 22 non-test sites. A fourth predicate,
however well typed, is the fifth containment cycle.

The designs without this bug do not have a better flag; they resume only
on an explicit action, into a new terminal id, and make two agents in one
terminal unrepresentable in the schema. The first of those is a product
decision about whether automatic resume stays a feature, so it is the
user's call rather than mine.

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

* docs(terminal): reconcile G6 with the recorded decision and assess its clauses

G6's body still demanded strictly-negative production LOC after the user
relaxed it to minimise-and-justify, so the gate had two conflicting pass
conditions and no single truth value. Its body now points at that
decision.

Assessed the remaining clauses against the branch rather than assuming.
Two fail structurally: more than one identity comparison and mutation
admission path still exist, and `terminal-input-quarantine.ts` is still
reachable from two production files.

Records why the quarantine is not subsumed by the superseded-PTY fence,
which I had assumed and checked. The fence refuses writes aimed at a
stale ptyId; the quarantine guards the user's next keystrokes landing on
the successor under its current, correct id — a case the fence never
sees. Removing it needs the recovery path to surface a different shell as
unresolved, not a deletion.

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

* docs(terminal): the input quarantine is load-bearing, not superseded

G6 lists "no superseded quarantine remains reachable" and this module was
assumed to be one. Disabling its single call site reproduces the hazard
it exists for — `cho hi; rm -rf x` reaching the shell — so deleting it
without a replacement re-opens command execution.

The replacement was costed by building it rather than estimated: +26
production LOC to thread the incarnation, ~+33 complete, and the
cross-remount state it needs outlives the destroyed pane so it becomes a
module about the size of the one deleted. Floor is roughly +140 to delete
88, and it would add a second identity comparison to a gate already
failing for having more than one.

The decisive part is that the route is not uniformly available: remote
runtime results carry no incarnation, old hosts cannot be made to publish
one, and mixed versions are the normal state. A paired client reads
unknown, which this program's own rule says is not proof — so either
every remote reattach surfaces unresolved, or a fallback is needed and
the only correct fallback is this module.

Whether to amend the clause or accept something weaker on remote hosts is
a user decision, so the clause verdict is left as failing rather than
quietly reclassified.

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

* refactor(runtime): collapse duplicate identity comparisons

G6 requires one identity comparison; five implementations existed across
two concepts.

Worktree-namespace identity had two: `runtimeWorktreeIdsEqual` and
`runtimeWorktreeIdentityKey` independently re-derived repoId plus
normalized path. Equality now derives from the key, so the comparison and
the sleep / mutation-queue keying cannot drift into two different rules —
which is exactly how the suffix-stripping bug reached production once.

Pane identity had three byte-identical leaf-UUID comparisons, in
orchestration `db.ts`, `lifecycle-reconciliation.ts`, and
`orchestration-legacy-process-identity.ts`. One copy moved to
`stable-pane-id.ts`, which already owns `PaneKey`, `parsePaneKey` and
`makePaneKey` and which all three already imported. No new module, no
branded type, no parallel comparison.

Net -14 production lines. The namespace oracle still bites: restoring the
filesystem parser inside the identity key reddens exactly its five cases.

The raw counts are not the actionable set, and the classification is
worth recording: of 409 non-test `worktreeId` comparisons, 71 are typeof
guards and 81 are sentinel tag checks. Most of the remainder are renderer
predicates over store rows where both operands are the same main-minted
id, so normalizing there would widen equality rather than correct it.

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

* refactor(terminal): finish a half-done fixture move and audit the rest

`xterm-bypass-event-fixture.ts` and `__fixtures__/xterm-bypass-event.ts`
were byte-identical apart from an import path. The `__fixtures__` copy had
zero importers and the live copy compiled as production — someone started
the move and left both. Dead copy deleted, live one moved, its three test
importers updated.

Audited the wider G6 clause by importer rather than filename: 32 test-only
files, roughly 3,300 LOC, currently compile as production; 4 of the 36
candidates have real production importers and are correctly placed. The
list is recorded in the goalposts.

Those 32 are almost all older than this program and outside the terminal
surface, so sweeping them belongs in its own change rather than inside a
terminal PR. The clause stays failing, with the remaining files named.

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

* docs(terminal): the fixture clause already holds where it matters

Checked what the build emits rather than reasoning from file paths. None
of the 32 test-only fixtures appears in `out/` — Rollup drops them because
no production entrypoint reaches them. On "compiles into the shipped
product", this clause holds today.

On the other reading it cannot be closed by moving files at all: both
production tsconfigs use bare `include` globs with no `exclude`, so a
`__tests__/` directory matches exactly like any other path, as does every
`*.test.ts` in the repo. Relocating 32 fixtures would remove nothing from
typecheck scope.

A sweep was started and stopped once this was verified, rather than
landing 32 moves across areas this program does not own for no gain. If
the intent is that typecheck scope should exclude test code, that is a
repo-wide tsconfig change with a different owner.

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

* docs(terminal): add plain-language design and test overviews

Two reviewable documents with diagrams, written so someone with no prior
context can follow what breaks, why, and what changed.

The design overview explains the five things stacked behind one terminal
rectangle, the 2 -> 19 -> 20 report, the three root causes, and the rule
underneath all of them: unknown is not dead.

The test overview explains why a green test proves nothing on its own,
the four-step mutation proof we adopted, and — the part worth reviewing
hardest — an honest account of what could not be proven and why, including
the properties that are true by construction and therefore have no guard
to remove.

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

* docs(terminal): add a self-contained visual report of the design and its evidence

Pre-renders every diagram to inline SVG in both themes so the report opens
offline and stays sharp when zoomed. States the gate/journey score and the
retractions alongside the fixes, so the unproven half is as visible as the
proven half.

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

* docs(terminal): record the finalized two-plane architecture decision

Adopts the data-plane proposal and adds the control-plane track it does not
cover: re-key ownership by pane, split orphan inventory out, then delete the
compensating code. Records that the host-authority alternative was refuted and
that the shipped keystroke fence is inert on the reattach path.

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

* docs(terminal): add the design brief the review counsel works from

Separates verified code facts from unverified leads so reviewers attack the
design rather than a reconstruction of it, and records which simpler
alternatives were already refuted and why.

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

* docs(terminal): report the design counsel's outcome and the live respawn bug it found

Three review rounds across two models replaced the two-record split with one
leaf-keyed record, deleted attach-time pane identity, and made orphans a
connect-time projection. Records that a shipped gesture still turns a healthy
remote shell into a duplicate agent resume, and that the renderer classifier in
that chain treats an error-message shape as proof of death.

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

* docs(terminal): correct the report — the respawn proof gate guards a minority path

A final review traced every auto-respawn route. The primary one converts the
reattach failure into a boolean before any classifier sees it, so the shipped
proof gate never runs there. Records that two of the six shipped changes are
narrower than claimed, and why their tests could not have caught it.

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

* docs(terminal): explain the landed design on its own terms

One leaf-keyed ownership record, orphans computed at connect, and replacement
shells only on positive proof — with the shipping order and the one product
trade the design asks the owner to accept.

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

* docs(terminal): rewrite the design explainer in plain English

The first version assumed the reader knew the codebase. Reframed around two
bugs, two fixes and one decision, with the jargon replaced by pane / program /
note / helper and a five-word glossary for what could not be avoided.

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

* fix(ssh): stop reading an identity mismatch as a dead shell

The relay reports a pane-identity mismatch by saying the pty was not found,
but it found it — comparing identity is how it noticed. Publishing that as
expiry made the renderer clear the binding and cold-restore with agent resume,
so a live shell gained a second agent on one transcript. Reachable today by
detaching a pane into a new tab, which changes the tab the relay froze at spawn.

Mismatch now carries its own token and the classifier refuses it as proof.
Genuine absence still expires, so a shell that really went away is not stranded.

The three failure tokens move to src/shared: main published them and the
renderer decided respawn on them, from two copies that had drifted apart.

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

* fix(ssh): stop sending pane identity on reattach

The relay froze pane identity at spawn, so moving a pane to another tab made it
refuse a live shell — and refuse by saying 'not found'. The comparison is
presence-guarded, so not sending the fields disarms it on every relay version
including ones already installed on hosts: no wire change, no redeploy.

Nothing is lost. It existed to catch a relay restart recycling pty-N for a new
shell, and in exactly that case pane and tab both still match, so it accepted
the wrong shell anyway. The incarnation the attach returns is what distinguishes
those, and it already crosses the wire.

Removes the whole client-side apparatus: the expected-identity type, its
per-lease derivation, its map, and the parameter threaded through four layers.

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

* docs(terminal): add tracked goalposts for the new design

Each goalpost is a behaviour with an oracle and the mutation that must redden
it, so 'proven' cannot be claimed from a green test. Records the anti-inert rule
as a first-class goalpost, since three guards in this program passed their tests
while sitting off the route production takes.

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

* docs(terminal): record that the recovery grant is dead code, deleting a design step

The lease stores a relay-native pty id and the caller passes the app form, with
a raw equality comparison between them, so the 30s grant cannot fire for a real
SSH pane. The death rule that existed to referee it is deleted rather than
built, and the dead path itself becomes a removal.

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

* docs(terminal): keep the full design detail in the repo

It only existed in an ephemeral job directory, so the plain-English explainer
had no durable source for its specifics — record shape, death rule, reattach
algorithm, migration order and the 25 oracles.

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

* docs(terminal): add a resume prompt for a clean session

Points at the goalposts as the contract, names the three goalposts whose oracles
are already written and red, and carries the process rules that were learned the
expensive way — prove guards reachable, verify mutations land, commit per step,
and never let a subagent write production files in a shared worktree.

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

* test(ssh): add the failing oracles for goalposts S3, S4 and S5

Intentionally RED: 14 clauses that fail against current behaviour and go green
under the changes named in new-design-goalposts.md. The branch is held unmerged,
so red here means unimplemented, not broken.

Each was verified to fail for the right reason and to flip green under the
identified fix, which was then reverted. Each pins the producer as well as the
consumer, so no clause can pass vacuously if its route is ever severed — the
failure mode that let three earlier guards ship inert.

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

* fix(ssh): stop fabricating an exit when a reattach fails

A failed attach never proves the shell exited. The relay answers not-found for
a pane-identity mismatch and for any id it merely cannot hand back, so treating
it as death sent the pane a synthetic `pty:exit { code: -1 }`, cleared provider
state, deleted ownership and expired the lease — four claims about a process we
know nothing about, on a shell that is usually still running.

Collapse every failure into the non-destructive branch that already existed a
few lines above (`restoreRequired = 'reattachAttemptsExhausted'` + wakeRecovery).
A branch collapse, not a new mechanism: goalpost S3.

Two tests pinned the deleted premise and are INVERTED rather than patched, so
the new intent stays covered:
- ssh-relay-orphan-abandon-paths: "retires the lease without a kill when the
  relay proves the PTY is gone" -> "leaves the shell running when the relay only
  reports the PTY as not found". Its comment claimed attach verifies liveness
  before answering not-found; it does not.
- ssh-relay-session: "invalidates and broadcasts remote PTYs that cannot
  reattach" -> "leaves an unreattachable remote PTY alone while its sibling
  reattaches".

Also repairs two clauses left red by c51be8072b (step A), which dropped the
expected-identity parameter and the expectedIdentityByPtyId map.

Mutation proof: restoring the destructive block reddens 6 of the 8 oracle
clauses in ssh-relay-reattach-exit-proof.test.ts; the 2 producer pins stay
green. Verified the mutation landed before believing the result.

Net production: -21 lines.

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

* fix(ssh): give an SSH pane binding one home

An SSH pane's durable binding lived in two persisted partitions. Main's spawn
wrote `ssh:<target>`; the relay's reattach write passed no hostId and landed in
`local`; the renderer has always published SSH pane membership to `local` on
purpose. So `durablyBoundPtyIdForPane` hedged ssh-first-then-local, read a
partition no live writer maintained, saw the arriving lease disagree with a
stale pty id, and bailed — supersession silently no-opped and both leases stayed
live. That is the STA-3077 2 -> 19 -> 20 mechanism.

`local` wins: it is the only publisher of pane membership and where
`mayCreate:false` is evaluated. Every reader and writer now names it.
- resolvePersistedStablePaneOwner / retirePersistedStablePaneOwner drop their
  connectionId parameter and read the default partition.
- the CAS write and both spawn upserts drop the hostId argument (each was an
  if/else that collapses to one call).
- durablyBoundPtyIdForPane stops hedging.
- a one-time load fold moves any legacy `ssh:<target>` ptyIdsByLeafId into
  `local`, preferring `local` on conflict, sequenced after the leaf remap so
  every folded binding keys on a UUID.

Side effect worth naming: the renderer never hydrated the `ssh:*` partition
(listKnownRuntimeHostIds filters to `runtime:*`), so the Issue #217 force-quit
binding protection had never worked for SSH panes. It does now.

Mutation proof, run as a 2x2 because the two edits can mask each other:
- fold disabled, reader local-only -> 1 clause reddens (the fold is live)
- fold disabled, hedge restored    -> 3 more redden (the reader is live)
- fold enabled,  hedge restored    -> ALL GREEN

That last row is why this commit adds an eighth clause: with the fold shipping,
the fold erases the divergent copy at boot, so the reader guard would have
shipped unproven — the exact failure mode G5 exists to catch. The new clause
rewrites `ssh:<target>` mid-session (orphan adoption still writes there) and
reddens when the hedge is restored, pinning the reader on its own.

Five clauses in ipc/pty.test.ts pinned the two-partition shape and are INVERTED
to the single home, each keeping an explicit arity check so a re-added partition
argument fails loudly rather than silently.

Net production: +9 lines (the fold is new state repair; the call sites shrank).

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

* fix(ssh): bind a pane through one producer so the fence is live on reattach

The superseded-PTY fence refuses a keystroke queued for a shell whose pane has
since bound a different one. It reads `ptyPaneKey` disagreeing with
`paneKeyPtyId` — and only spawn ever wrote those maps. Reattach bound the pane
through `runtime.registerPty` instead, so the maps never learned the successor,
`isSupersededPtyId` returned false by construction, and the fence was inert on
the one path it was built for. Goalpost S5; a defect in already-shipped work.

Collapse to one `bindPaneShell` producer that writes the durable record and the
fence maps together. All three binding paths call it: the relay reattach and
both spawn handlers. Error policy stays at the call sites because it genuinely
differs — a caller that just created a shell must clean it up on a failed
durable write, a caller that merely reattached must not detach anything.

The paneKey is composed from the tab that holds the leaf *now*, resolved from
the live layout, not from the tabId frozen in the lease. Only the leaf half of
a pane key is remint-stable; `detachTerminalPaneToTab` moves a live pane and its
PTY into a new tab, so a stored tabId names the tab the pane left.

Mutation proof, both sub-guards isolated:
- drop the `rememberPaneKeyForPty` call    -> 3 clauses redden
- prefer `args.tabId` over the live layout -> 1 clause reddens
The second clause is new in this commit. Every pre-existing clause in the fence
oracle used one tabId on both sides, so a producer that simply forwarded
`lease.tabId` would have gone green and shipped the tab bug unnoticed.

Two source-text clauses are STRENGTHENED, not relaxed. They previously required
the relay to hold a `persistPtyBinding` call of its own and merely forbade an
ssh-partition argument on it. The relay now has none, so they assert ZERO direct
binding writes there plus a `bindPaneShell` call — a second bind producer is
exactly the defect this removes.

Also repairs a latent false green: the "persistence fails" case in
ssh-relay-session-reconnect-incarnation was passing because a missing mock made
the call throw a TypeError that happened to emit the console.error it asserted.
The failure is now injected at the producer, so it is a real oracle for "a
thrown durable write must not detach the PTY".

Net production: +60 lines. This is the one step in the program that grows;
the shrink arrives with S8's deletion. Reported rather than smoothed over.

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

* feat(terminal): show an unreachable pane as disconnected with two actions

Ships with S3. Collapsing the fabricated exit removed a lie, but it left the
pane frozen: `restoreRequired` never crosses to the renderer, so a relay-driven
reattach failure had no user-visible signal at all, and the renderer's own
reattach arms showed a raw error toast with no way to act.

An unproven failure now renders the pane as disconnected with exactly two
explicit actions — "Try again" (remount against the same shell via
requestTerminalPaneRecovery) and "Start a new terminal" (retire the binding,
then spawn fresh). Nothing infers death and nothing auto-spawns; the user
decides, because at that point no one knows whether the shell is alive.

The two actions are the same two things the code already did, moved behind a
click: the retry is the existing pane-recovery request, and "start a new
terminal" is the existing clearExitedPanePtyLayoutBinding + clearTabPtyId +
startFreshColdRestoreAgentResume sequence that used to run automatically on a
"proven gone" error. No new IPC channel: the silent-respawn decision was always
renderer-local.

Copy constraint, enforced by an oracle rather than a review note: the banner may
never assert the shell exited. STYLEGUIDE.md:236 already forbids result verbs
without result data, and a failed attach is not result data. A test asserts the
rendered text matches no death verb and shows no wire token.

`TerminalRemoteRuntimeReconnectBanner` is renamed `TerminalPaneDisconnectedBanner`
— it now serves any transport, and per AGENTS.md the name must say what it holds.
Existing i18n key strings are kept verbatim so no shipped translation breaks;
the SSH copy is additive (4 new en.json keys).

`describeReattachFailure` is deleted with its last caller. Its two cases were
not dropped: "keeps the wire token out of the pane" is re-asserted against the
new copy, which is a stronger place for it.

Renderer production (excluding the pure rename): +28 lines.

* refactor(terminal): delete the SSH pane recovery grant, which could not fire

For a disconnected pane, `recoverTerminalPane` consulted a recently-expired SSH
lease and, on a match, spawned a replacement shell. It could never match: leases
store a relay-native pty id (`pty-7`, normalized on every write) while the
runtime registers the app-form id (`ssh:<conn>@@pty-7`), and the comparison was
a raw `===`. The branch was also unreachable for a local pane, which has no SSH
lease. Goalposts S6 and S8.

A characterisation oracle lands FIRST and proves it, rather than assuming it:
ssh-pane-recovery-grant-reachability.test.ts mints BOTH id forms from the
production helpers — never as two hand-typed literals — so it tracks the real
namespace split instead of restating it, and seeds a lease that qualifies on
every other predicate (state, worktree, tab, leaf, grace window). Anti-vacuity
assertions pin that control actually reaches the gate rather than bailing early.

Mutation proof, run before the deletion: normalizing the comparison at the
`lease.ptyId === ptyId` site makes the grant fire and reddens the oracle. That
is the exact "fix" someone would reach for, so the oracle is pinned to
unreachability rather than to the throw.

Deleted: the grant tail, the `terminalPaneRecoveryByIdentity` dedup map (whose
only consumer was the grant), and the dead `ptyId` parameter.
NOT deleted, and worth naming because over-deleting here would break users:
`getRecentExpiredSshLease` itself, `hasRecentExpiredSshLeasePane` and
`SSH_PANE_RECOVERY_GRACE_MS` all stay. Their other two callers pass `ptyId`
undefined, which short-circuits the broken comparison — those are live today and
feed headless-mobile terminal-tab visibility.

Also NOT done: deleting only the gate while keeping the spawn. That would have
granted a respawn to every disconnected pane — a behaviour change in the
dangerous direction. The refusal is what stays.

Four tests pinned the grant. All were seeded through a helper that stores the
lease id as the same literal it registers as the runtime pty, with a null
connectionId — a shape production cannot mint. Three are INVERTED, keeping their
scenarios; the fourth is now tautological and carries a comment saying so rather
than being left silently hollow. The oracle's own counterfactual control is
inverted by this deletion too, which is recorded in the file: its flip from
grant to refusal is what "inert" means here.

Honest limit, stated in the oracle rather than smoothed over: unreachable BY
CONSTRUCTION for SSH panes; for a local pane, unreachable only up to a random
UUID collision.

Net production: -32 lines.

* docs(terminal): record S3, S4, S5 and S8 proven, and G1 missed

All seven step goalposts are now proven. G1 (net-negative production) is NOT met
at +83 and is reported as a miss with a per-step breakdown rather than reframed.

Also records the near-miss G5 caught: S4's reader guard showed no mutation
response because the load fold had already erased the divergent state at boot.
It was correct and would have shipped unproven. An added clause pins it.

* fix(terminal): a reattach not-found is not proof the shell is gone

Closes the last live route to the reported duplicate agent resume (RC2), found
by the E2E harness rather than by reading: when a relay is stalled and replaced,
the fresh relay has no memory of `pty-1` while the old shells keep running under
its predecessor. It answers not-found, and the renderer read that as proof.

A not-found means the relay WE ASKED cannot hand that id back. That proves an
exit only if the relay process that minted the pty is the one answering. The
design says exactly this (D3 row 2), and gates the grant on `relayInstanceId`
equality — a field step E-2 never built. `SSH_SESSION_EXPIRED` is not
independent evidence either: its ONLY producer is that same not-found mapping in
reattachSshPtySession, and the token's own doc comment claimed "the host proved
the session is gone", which it never did.

So `isProvenSshSessionGoneError` returns false. Both reattach arms now always
take the non-proof path and surface the pane as disconnected, which is what the
owner approved in D1 — and what makes that affordance load-bearing rather than
near-unreachable, since it was previously only reached by errors that were
already rare.

The respawn tails are deliberately NOT deleted. The design preserves the grant
as a conditional for E-2, so the decision point stays and a clause pins that
nothing reaches it meanwhile. This is the one place in the program where an
unreachable branch is kept on purpose, and it is labelled as such.

Tests: three clauses asserted a not-found proves death and are INVERTED, with
the reasoning recorded. The #12101 cold-restore case reached the spawn door by
throwing a not-found; that door is now opened by the user's "Start a new
terminal", so the test drives that instead and keeps all four of its original
assertions verbatim — strictly better coverage, since it now pins that the
automatic respawn stopped AND that the door still works.

Mutation proof: restoring the old predicate makes the new no-respawn clause fail
with "expected connect to be called 1 times, but got 2", confirming it reddens
on the real production route rather than passing vacuously.

* test(ssh): add E2E oracles for pane cardinality and duplicate resume

Both reported failures now have end-to-end coverage against a real Docker
OpenSSH relay, gated on ORCA_E2E_SSH_DOCKER like the rest of the suite.

- ssh-reconnect-pane-cardinality-across-partitions: three real reconnect cycles;
  after each, pane ids unchanged, exactly one live lease per leaf, exactly one
  remote shell per pane key, and exactly one pty id bound per leaf ACROSS BOTH
  durable partitions. Two partitions agreeing is tolerated; two naming different
  shells is the S4 divergence and fails. PASSES.
- ssh-reattach-does-not-resume-agent-twice: the host itself records one line per
  shell launch via a .bashrc hook keyed by ORCA_PANE_KEY, so a duplicate resume
  is counted at the source rather than inferred. Fault is SIGSTOP on the
  detached relay. PASSES.
- ssh-disconnected-pane-affordance: written whole but held as test.fixme. The
  banner needs the target CONNECTED while a single pane's attach fails, and both
  host-side faults drove the target out of connected instead. Held rather than
  deleted so it runs the day a seam exists; the reason is measured, not assumed.

New helpers: docker-ssh-relay-stall (SIGSTOP/SIGCONT, reads the stop back off
/proc so a fault that did not land cannot make an oracle vacuous), and
remote-pane-launch-transcript.

Note for whoever picks these up: a stalled relay leaves two detached relay groups
on the host, and readDockerSshRelayProcessSnapshot throws on more than one, so
call it before the fault.

Writing these is what surfaced RC2 surviving S3 — fixed in 7cd7fef927.

* fix(ssh): fold the pane incarnation with the binding it fences

Found by adversarial review. `persistPtyBinding` writes the binding and its
incarnation into the SAME partition, and the incarnation is what its CAS
compares. Step P's load fold moved only `ptyIdsByLeafId`, so after upgrade a
pane whose incarnation had been written to `ssh:<target>` kept the guard in the
partition nothing reads: `resolvePersistedStablePaneOwner` read `undefined` from
`local`, the CAS then compared undefined against undefined, and the incarnation
half of the fence passed for any value until the next write healed it.

Not data loss and not a wrong-shell bind — the ptyId half still held — but a
guard silently weakened by a migration is the exact shape this program keeps
finding, so it is closed rather than noted.

Mutation proof: disabling the incarnation half of the fold reddens the new
clause; the other eight stay green.

One clause in persistence.test.ts asserted the incarnation survives a reload in
the SSH partition. Its subject is that the reconciled value is preserved, not
which partition holds it, so the assertion follows the binding to its one home
and additionally pins that the ssh partition no longer keeps a copy.

* docs(terminal): record the two defects found after the goalposts were met

RC2 survived S3 and was found by writing the E2E test, not by reading. The load
fold left the incarnation half of the pane fence behind and was found by
adversarial review. Both were invisible to unit oracles that had already gone
green, which is the useful part of the record.

* fix(terminal): close four defects found by adversarial review

Four reviewers over the diff, every finding put to an independent skeptic. Nine
of twelve agents died on prompt length, so most findings arrived UNREFUTED
rather than refuted — I checked those myself instead of counting them clean.
Four were real.

1. "Start a new terminal" resumed the agent instead of starting a new one.
   The action passed the cold-restore startup, which carries the agent's
   providerSession. That was correct for the automatic respawn it replaced,
   because that only ran on PROOF the shell was gone. Behind this button the
   shell is probably still alive, so it put a second agent process on one
   transcript — the exact defect this pane exists to prevent, reintroduced by
   the fix for it. Now starts a genuinely fresh shell, which is also what the
   button says.

2. The banner was never retracted. The app-SSH transport publishes no recovery
   states, so nothing cleared the card: it sat over a live shell with armed
   buttons. Both actions now clear it before acting.

3. The load fold destroyed bindings it could not move. When a tab existed only
   in the ssh partition there was no local layout to fold into, and the code
   cleared the source anyway — deleting the only record of that binding. It now
   leaves such a tab alone: with no second home there is nothing to disagree
   with and nothing to fold.

4. The relay registered the pty under the lease's frozen tabId while
   bindPaneShell bound and fenced it under the live one, splitting a moved pane
   across two tabs and ensuring a mobile surface for the tab it left.
   bindPaneShell now returns the resolved tabId so both use one coordinate.

Also repairs a reliability-gate manifest entry the banner rename broke. That
would have been caught by the pre-commit lint gate, which I had been skipping
with --no-verify; the full-repo sweep caught it instead.

Mutation proofs, each verified to land before being believed:
- restoring the resume reddens on `registerAgentLaunchConfig` and, with that
  clause disabled, on the spawned command being
  "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'"
- disabling the fold's move reddens the ssh-only-tab clause
- registering under lease.tabId reddens the moved-tab clause

The first clause was VACUOUS on its first attempt and is recorded as such: the
fixture had no resumable agent, and it asserted a field name the spawn path does
not use. It now pins the fixture itself, so it fails loudly rather than going
quiet again if `codex` stops being resumable.

* fix(terminal): drop the remote-host assumption from the disconnected copy

The banner said the shell 'may still be running on the host'. The reattach arm
that publishes it is not SSH-only — a local or folder-workspace pane reaches it
too, and there is no host to speak of there. The claim that matters is that the
shell may still be running, which holds either way.

* fix(terminal): close round-2 review findings, including one regression

Round 2 ran narrow per-area scopes so agents stopped dying on context: 7 of 7
reported, versus 3 of 12 in round 1. Two findings survived refutation and both
were real.

1. REGRESSION I INTRODUCED. `bindPaneShell` resolved the tab from the live
   layout for EVERY caller. That is right on reattach, where the lease's tabId
   is the frozen side — but backwards on spawn, where the caller's tabId is
   fresh truth and the persisted layout is the stale side inside the renderer's
   publish debounce. Breaking a pane out into a new tab and spawning into it in
   that window resolved back to the tab the pane had just left, writing the
   durable binding and the fence under one tab while the lease and the runtime
   registration used the other — the split-coordinate defect step F exists to
   remove, reintroduced on the spawn path.
   Live-layout resolution is now opt-in via `tabIdMayBeStale`, set only by the
   reattach bind. A clause pins that no spawn-side call sets it.

2. The fold moved every incarnation even for a binding it had deliberately left
   in place, splitting a pane's binding from the incarnation that fences it —
   the same defect 994733d8b1 closed, in the other direction. Incarnations now
   move only with the binding they belong to. The reload clause that had been
   inverted for the fold is restored to its original assertion, because an
   ssh-only pane now correctly keeps both halves together.

Also closes a live route to the reported duplicate resume that my earlier fix
missed. `isProvenSshSessionGoneError` covered the rejected-promise arms, but a
reattach can also report expiry through the transport's error callback and then
resolve falsy; those two branches still cleared ownership and cold-restore
resumed the agent. A skeptic refuted this as pre-existing rather than caused by
this branch, and that is correct on causation — but it is a live second route to
the exact defect this work exists to remove, so leaving it would make the claim
that duplicate resume is fixed false. Both branches now surface the disconnected
pane.

Five tests pinned that callback respawn. Two are INVERTED to the disconnected
outcome; two keep their real subject (stale-callback fencing, delayed parked
snapshot) and now reach a replacement shell through the banner's "Start a new
terminal", which is the new production route; one — the cold-restore resume
after expiry — is inverted to assert no resume command and no agent launch
config, with a fixture pin so "no resume" cannot pass vacuously.

* fix(terminal): close round-3 review findings on tab resolution and the fold

Round 3, narrow scopes again: 9 of 9 agents reported. Two findings confirmed.

1. A thrown durable write lost the resolved live tab. `bindPaneShell` resolved
   the tab internally, so when `persistPtyBinding` threw, the relay's `bind`
   stayed null and it registered the pane in the runtime graph under the frozen
   lease tab — splitting the graph from the durable record it had just moved.
   Resolution is now an explicit `resolvePaneShellTabId` the relay calls BEFORE
   the write, so a throw cannot lose the answer. This also deletes the
   `tabIdMayBeStale` flag added a commit ago: the reattach resolves its own tab
   and passes a live one, and spawn callers simply pass theirs. The distinction
   is now carried by which caller resolves, not by a flag they must remember.

2. The fold could pair a binding with an incarnation that was never written
   alongside it. Where both partitions named a leaf, local won the binding but
   the incarnation was copied across independently — so local's pty could end up
   fenced by the superseded partition's incarnation. `persistPtyBinding`'s CAS
   compares both, so that pane's next legitimate update would be refused. An
   incarnation now moves only when the pty it belongs to is the one that ends up
   bound.

   My first attempt at this over-corrected and skipped the case where both
   partitions name the SAME pty — where the incarnation does belong with it. The
   existing clause caught that immediately, which is the fixture doing its job.

Also hardens `findTerminalTabIdForLeaf`: it now requires the tab to still exist
in `tabsByWorktree`. A layout entry outlives the tab it described, and binding a
live shell to a deleted tab registers a pane under a ghost and can resurface it.
The fence oracle's fixture gained the live tab it was missing, so that clause
cannot pass by resolving nothing.

New clause covers the divergent-pty case the reviewer named — the two partitions
naming DIFFERENT ptys for one leaf, which is the divergence being migrated and
was previously untested.

* fix(terminal): check tab liveness without depending on a worktreeId match

A reviewer asked, correctly, whether lease.worktreeId always matches the
tabsByWorktree key exactly — including for a folder workspace, whose worktreeId
carries a `::workspace:<uuid>` suffix and is matched by full-string equality.

Rather than assert that invariant, this removes the dependency on it. Tab
liveness is now checked across every worktree instead of under one key. A leaf
id is a UUID, so there is nothing to disambiguate by worktree, and the resolver
no longer has an answer that depends on two strings agreeing — which is exactly
the class of full-string comparison that produced issue #12474 in this area.

Had they diverged, resolution would have silently returned undefined and fallen
back to the stale lease tab, quietly restoring the moved-pane bug for folder
workspaces only. Failing open like that is worse than the check itself.

* style(terminal): keep the membership authority under the max-lines limit

My previous comment pushed the file to 301 lines. AGENTS.md forbids a max-lines
disable or a per-file bump, so the comment is trimmed to the repo's concise
standard and the liveness check folded into the existing condition.

* fix(ssh): resolve every incarnation in the pass that clears its binding

Round 4 confirmed one defect, in my own round-3 fix. The filter that stopped an
incarnation following a LOSING binding also stopped it being deleted — while the
binding itself was still cleared. So a conflicted leaf left the superseded
incarnation behind with nothing to fence: durable fence state in a partition
holding no binding, which is the one-home invariant this step exists to
establish, broken by the code establishing it.

The reviewer also named the test gap exactly: the clause I added asserted only
that the value was not copied into local, never that it was gone from the
partition it lost in. Both are asserted now.

Incarnations are resolved in the same loop that clears the bindings, so no
binding can be cleared without its fence being resolved. Three outcomes, by
which pty ends up bound:
- this binding moves (local had none)   -> its incarnation moves and OVERWRITES
  any local value, because a local incarnation with no local binding is a
  leftover rather than a fence. That case previously synthesized a pair.
- both name the same pty                 -> keep whichever fence local holds.
- local wins with a different pty        -> drop this incarnation with the
  binding it belonged to.

The second and third outcomes were flagged by the same reviewer as real but
attributable to my earlier commit rather than that one. They are the same defect
class, so they are fixed here rather than filed.

Two new clauses: the superseded incarnation is deleted, not merely uncopied; and
a moving binding overwrites an orphaned local incarnation.

* fix(ssh): never pair a moving binding with a fence that was not written for it

Round 5 ran a mechanical ten-case matrix over the fold twice, independently. Both
passes landed on the same primary defect, and it is one my previous fix created.

Case 7: the ssh partition holds a binding with NO incarnation, and local holds a
stale incarnation with no binding. The binding moves into local and inherits that
leftover, producing (arriving pty, unrelated incarnation) — a pair no writer ever
produced. persistPtyBinding's CAS compares both halves, so the pane's next
legitimate update is refused. The previous fix only replaced local's leftover
when the ssh side had an incarnation to replace it WITH; absent one, the leftover
survived. A moving binding now takes the ssh fence whatever it is, including
absent, in which case local's is deleted.

Also fixes the bookkeeping both passes flagged: the function defaulted
`workspaceSession` at the top, so a profile with no local session could be
mutated on a path that then returns false — a mutation with no save scheduled.
It now returns early instead: with no local session there is nothing to fold
into, which is also the honest reading.

Two findings are deliberately NOT fixed, recorded rather than silently dropped:
- An ssh incarnation whose binding was already missing BEFORE the fold survives,
  because iteration is binding-driven. It is a pre-existing orphan isolated in a
  partition no reader consults for pane bindings, and reinterpreting it is not
  this migration's business. The comment claiming an incarnation never outlives
  its binding overclaimed and is corrected to say what the code does.
- Two ssh partitions carrying the SAME tab and leaf resolve by object-key order.
  A tab belongs to one worktree on one host, so this is not a shape production
  writes; making it deterministic would mean inventing a precedence rule for a
  state that should not exist.

New clause covers case 7 directly. The matrix cases both reviewers named as
uncovered are now covered except the two above.

* refactor(ssh): delete the pane-binding fold; its premise was false

The migration moved legacy `ssh:<target>` pane bindings into `local` and cleared
them, on the theory that the ssh partition was a stale spill of the desktop
plane's state. Investigation of both hypotheses the team raised disproved that:

- NOT cross-version compat. The introducing commit says it is for CONCURRENT
  multi-host. Nothing about the partition crosses the wire (zero references under
  src/main/runtime/rpc/), and the payload that does cross the SSH boundary —
  RemoteWorkspaceSnapshot — is projected from `local`. The only downgrade-compat
  comment protects `local`, the other direction.
- NOT multi-client. PersistedState is one file on one machine; phones and CLI are
  RPC clients into that same process. Orca's real per-client state is
  `mobileClientTabSelectionsByDeviceId`, 13 lines above in the same struct, and
  it carries selections only — never a ptyId.

What it actually is: the headless/CLI/mobile plane's OWN home, written and read
deliberately across several tickets (STA-3463, STA-3465), with tests that assert
that partition by name. So the fold was not tidying a spill — it was erasing
another plane's live state. Measured symptom: a split SSH tab would disappear
from mobile while its shell kept running.

The desktop plane's fix never needed it. Supersession multiplied panes because
`durablyBoundPtyIdForPane` hedged into the other plane's copy; reading `local`
alone is the fix, and it stands without any migration.

Deleting rather than redesigning, because the redesign had no target: five review
rounds each found a defect in that function, three of them inside the previous
round's fix, and every one was a cell of a merge matrix that only existed to
serve a premise that was false.

The `boundPtyIdsAcrossPartitions` clause is INVERTED, not dropped. It required
the two partitions to AGREE after load — which encoded the false premise, and is
what made a migration look necessary. It now asserts the narrower, stronger
property: what the desktop plane resolves follows `local` alone, whatever the
other plane holds.

Also proven, and the reason unification was NOT attempted: worktreeId is
`<repoId>::<path>` where repoId is a randomUUID minted client-side at repo add
(orca-runtime.ts:18722), so two servers cannot collide on one. Host scoping was
not protecting against that here — but the two planes' opposite choices are each
deliberate and each test-pinned, so choosing a winner is an architecture call,
not a cleanup.

Net production: -75 lines.

* fix(ssh): arbitrate on the pane, not the tab its lease was written in

Correctness review round 1 on #13326: 5 raised, 2 confirmed, both real.

1. Arbitration looked the durable binding up under the lease's FROZEN tabId.
   `detachTerminalPaneToTab` moves a live pane and its PTY, and nothing re-keys a
   lease, so after a break-out the binding lives under the new tab and the lookup
   found nothing. The bound shell then lost to recency and supersession expired
   the pane's OWN lease; the follow-on scrub could not clean up either, because
   it matches lease.tabId against the layout's tabId. Reconnect skipped the
   expired-but-bound PTY and reattached the stale winner onto the pane.

   The leaf is the stable half of pane identity, so the lookup now prefers the
   named tab and falls back to wherever the leaf actually is.

2. Arbitration read the desktop plane only while the scrub reached into
   `ssh:<target>`, so a headless-plane pane was invisible to the ranking and
   then lost its live binding to it.

   Fixed at the reader, not the scrub: local FIRST, headless plane only as a
   fallback. That is NOT the STA-3077 hedge — that bug was preferring
   `ssh:<target>`, letting a copy no live writer maintains outvote the real
   binding. A fallback consulted only when local is silent gives a headless-owned
   pane a vote without ever outranking a live desktop binding.

   Proven by mutation: swapping the two back to ssh-first reddens 4 clauses,
   including the ten-reconnect cardinality one.

Also closes two fulfilled-result routes to the duplicate agent resume that the
earlier RC2 fix missed — `handleReattachResult` respawned on a result flagged
`sessionExpired` and on a result carrying no pty id. A skeptic refuted both as
pre-existing rather than PR-caused, which is correct on causation, but they are
live routes to the defect this PR claims to fix.

Scoped to SSH panes only. The first attempt diverted every pane and broke three
daemon tests — correctly: a local provider is authoritative about its own ptys,
so "cannot reattach" there is not the ambiguous evidence it is for a relay that
may have been replaced. New clause covers both routes; disabling the diversion
reddens it.

43,253 pass; the 7 failures are pre-existing on clean main in this environment.

* fix(ssh): supersede on the leaf, so a moved pane retires its own predecessor

Correctness round 2. This is the reported cardinality growth in its surviving
form, and it is the sharpest finding of the review so far.

Supersession matched sibling leases on `(worktreeId, tabId, leafId)` and bucketed
duplicates under the same key. A lease freezes its tabId when written, and
`detachTerminalPaneToTab` moves a live pane — so after a break-out the pane's
next lease carries the NEW tab and its predecessor carries the old one. The two
never match, the predecessor is never superseded, and the live count grows on
every reconnect. Exactly the reported 2 -> 19 -> 20, for any pane that has been
moved between tabs.

Round 1 fixed the same frozen-tabId mistake at the binding LOOKUP. It did not fix
it here, at the sibling match and the bucket key, which is why the bug survived a
round. Both are now keyed on the leaf — the stable half of pane identity, per
stable-pane-id.ts: the tab half changes on break-out.

Two clauses cover it: a predecessor whose lease names the tab the pane left is
superseded, and the live count stays flat across ten reconnects that each land in
a new tab. Restoring `tabId` to either the match or the key reddens both.

E2E re-run against a real Docker relay after the change: 2 passed. Full suite
43,255 pass; the 7 failures are pre-existing on clean main in this environment.

* fix(ssh): a supersession decided from one plane only mutates that plane

Correctness round 2 confirmed finding. Arbitration ranks leases using the desktop
plane's binding, then handed its losers to a scrub that walked BOTH planes — so
the headless/CLI/mobile plane's binding was deleted for a lease it never got to
vote on. Its owner then has no durable record to reattach that shell by, and can
fall back to recency or orphan a shell that is still running.

`clearSshRemotePtyBindingsForLeases` now takes `arbitratedFrom`. A decision
reached by reading one plane may only mutate that plane. An explicit expiry or
termination is plane-agnostic — the pty is gone for everyone — so those callers
pass nothing and still scrub both, which is what the existing
`markSshRemotePtyLease` oracle pins.

I initially assessed this as not-a-defect, reasoning that local winning IS the
STA-3077 fix. That was about RANKING and did not justify DELETING the other
plane's record; the round-2 verdict was right and I was wrong.

It did also catch a comment of mine that had gone stale — a clause claimed the
other plane was "deliberately left alone" while the code cleared it. The comment
now says what the code does and why.

Mutation: letting arbitration scrub both planes again reddens the new clause.
43,256 pass; the 7 failures are pre-existing on clean main in this environment.

* fix(terminal): put the unreachable-pane guards in one place

Correctness round 3. The leaf-keying from round 2 came back clean, twice and
independently. But the renderer produced findings for a third consecutive round,
and they were all one shape: `publishUnreachablePane` is called from seven sites,
each needing the same guards, and a different one was missing at each.

So this stops patching sites and moves the guards into the publisher:

- `disposed` — a late rejection republished a card for a numeric pane id that had
  already been reused, giving a fresh pane a phantom card whose actions closed
  over a dead session.
- `connectionId` — the deferred catch is not SSH-only. A local/daemon pane could
  be shown an SSH ambiguity card it can never clear, and would loop: retry
  remounts, reattach rejects, card returns. Its provider is authoritative about
  its own ptys, which is exactly why the two branches in handleReattachResult
  already had this guard — and why the catch needed it too.

Two more real defects in the banner's own actions:

- "Start a new terminal" passed `null` to suppress the saved agent startup, but
  connect FALLS BACK to the startup its transport was constructed with whenever a
  per-call field is absent (pty-transport.ts). So the "fresh" shell could resume
  the same provider session — the duplicate transcript this pane exists to
  prevent, for the third time in this button. `suppressSavedStartup` makes the
  suppression explicit; `??` means passing null could never have worked.
- It also cleared both durable bindings BEFORE the spawn, and discarded the
  promise. A spawn that resolves null left the pane blank, unbound, and with no
  way back to a shell that may still be running. Bindings are now cleared only
  once a replacement actually starts, and the card returns if it does not.
- "Try again" voided its boolean; a declined remount left no card and no shell,
  strictly worse than the toast it replaced. It now republishes.

The strengthened clause is the point: the old one asserted the ABSENCE of
per-call startup fields on a mocked transport, which passes whether or not the
production fallback fires. It now asserts the explicit suppression, and reddens
when the flag is dropped.

Four fixtures were under-specified — they drive SSH reattach scenarios but never
seeded an SSH repo, so `connectionId` was null and they had been passing without
the pane being SSH at all. Seeded, not weakened.

43,256 pass; the 7 failures are pre-existing on clean main in this environment.

* fix(terminal): suppress the whole saved startup set, not field by field

Round 4 self-check on my own round-3 fix. `suppressSavedStartup` guarded four of
the six values `connect` falls back to — `launchAgent` and
`startupCommandDelivery` still inherited from the transport's constructor. Adding
the guard per field is precisely how those two were missed, and the guarded
expressions had become unreadable.

The saved values are now one object that `suppressSavedStartup` drops wholesale.
A field added later is covered by construction rather than by remembering.

Found by asking the question the review lens was given rather than waiting for
its answer: does the suppression cover EVERY channel, or only the ones I noticed?
It did not.

* fix(terminal): stop stranding a local pane whose restore fails

The unreachable-pane card is SSH-only: a local, daemon or runtime-host pane has
no connection to be unreachable ON. Consolidating that guard into the publisher
made it silent, and four callers paired the now-conditional publish with an
unconditional `return` — so on the deferred-reattach path, which unlike the
direct-SSH one is not nested under `connectionId`, a local pane got no card, no
error and no replacement. Frozen, with a stale binding.

The diversion now reports whether it took ownership of the failure, so a caller
can only stop when something actually handled it. A pane that cannot show the
card falls through to the replace-in-place it had before, which is correct: its
provider is authoritative about its own ptys.

The two direct-SSH sites are inside `if (connectionId)`, so they are unchanged
in behaviour; the return value simply makes the pairing impossible to get wrong
at the next call site.

* docs(terminal): put two comments back on the thing they describe

The lease-healing docblock had drifted onto durablyBoundPtyIdForPane, which
neither retires leases nor returns a count, leaving the real healing entry point
undocumented and its neighbour carrying two contradictory descriptions.

The suppression comment claimed to cover "every startup value this transport was
constructed with"; env/envToDelete are constructor values and deliberately stay
outside the set. Nothing behavioural changes here — but a comment that overstates
a boundary is how the next maintainer picks the wrong one.

* docs(terminal): do not claim a remote runtime is authoritative about its ptys

A remote runtime reaches its pty over a connection that can fail, so a failed
reattach proves no more there than it does over SSH. It keeps replacing in place
only because the card is SSH-scoped, not because its provider is authoritative.
Say that, so the limitation is deliberate rather than implied.

* perf(ssh): stop fsyncing the whole store from the main thread on reconnect

supersedeDuplicatePaneLeases runs at the top of every reattach pass, and when it
retires anything it flushed synchronously. flushOrThrow fsyncs a multi-MB file
from the Electron main thread — this file already documents (see flushAsync) that
on a stalled network profile mount that syscall is uninterruptible, so the app
stops repainting and no deadline can bound it, because the deadline's own timer
is stuck behind the same block.

The population that hits this is exactly the one the heal exists for: an upgraded
install carrying accumulated duplicates. It now awaits the async durable twin its
neighbours on this path already use. Still "OrThrow", because a retirement that
is not durable must not be believed — the rollback is unchanged.

* fix(ssh): make a parked PTY delivery expire instead of going dark for good

Exhausting the per-generation recovery budget parks one PTY's delivery rather
than dropping the shared relay channel — right, because the channel is shared and
a retry count proves nothing. But the only escape it named was "the next relay
open", and a channel that stays healthy never gives it one. That leaves a pane
with no output and no way back, which is the exact state this change exists to
prevent; before this branch, exhaustion dropped the channel and the reconnect
ladder recovered the pane (loudly, at every sibling's expense).

The park is now a cooldown rather than a verdict: the next rejected frame after
it starts a fresh budget. Recovery is rate-limited, never abandoned, and the
containment that made parking right in the first place is untouched.

Elapsed time, not a timer, so there is nothing to cancel on teardown and no late
fire after dispose.

* fix(ssh): let a due park past the retired-delivery filter, and prove it

The cooldown added in 124e00e8a8 was reactive: it needed a later rejected frame
to reach recovery. But retirement is ours, not the host's — a stalled source keeps
publishing the very token we retired, and acceptPtyData drops those frames before
classification. So the wake-up could never fire in the case that actually happens,
and the pane stayed dark exactly as before.

A parked PTY past its cooldown is now let through that filter. It costs nothing:
the frame is re-classified as rejected and re-retired, so no output reaches the
terminal — it only regains the ability to ask for recovery.

The test shipped with that commit could not have caught this: it woke recovery
with a fabricated NEW delivery token. Retirement holds one key per relay PTY, so
that frame overwrote the key and un-retired the original — the assertion passed
with the wake-up path fully broken. It now reuses the original token throughout,
and reverting the guard above reddens it.

* refactor(terminal): one i18n namespace per component, and two notes worth keeping

The disconnected banner was renamed but kept reading five keys under the old
component's namespace while its new strings sat under the new one — a component
translating from two namespaces at once. Keys moved across all five locales;
values and behaviour unchanged.

Two comments earn their place. durablyBoundPtyIdForPane deliberately does NOT
require the tab to still exist, unlike findTerminalTabIdForLeaf which must —
adding the "missing" check there would make arbitration retire shells more
eagerly, which is the opposite of what this change is for. And the respawn after
the proof check in the direct-SSH catch is unreachable today by construction;
saying so stops it reading as forgotten code.

* fix(ssh): unknown PTY liveness is not proof of death

hasPty is three-state and says so: null means the provider has not listed the
host yet — ignorance, not death. The retry gate tested it with `!`, which reads
null and false alike, so it ended recovery on ignorance.

That state is not exotic: a reconnect builds a fresh provider with an empty set,
which is exactly the moment rejected frames arrive. The attempt was deleted and
no retry scheduled, and because the delivery token was already retired, no later
frame could revive it — the pane stayed dark. It also short-circuited the park
cooldown, since parkedAt was never set on an entry that no longer existed.

Only an explicit false stops us now. The proven-exit clause still pins that.

* fix(terminal): do not unbind a live shell the new-terminal spawn adopted

While the card is up the pane keeps its durable binding on purpose, so a failed
replacement can put the card back. But main resolves a stable pane's owner from
that same binding: if the shell answers again between the failed reattach and the
click, the spawn adopts it and returns its id. Nothing was replaced, and clearing
the binding then unbound a shell that is live and attached to this very pane —
leaving it running with no durable record of what it owns.

Detected by identity: an id equal to the one we were replacing means adoption,
not replacement. Suppressing adoption outright would need a new spawn option
plumbed renderer -> transport -> IPC; the user-visible oddity that remains is
getting the old shell back rather than a new one, which is benign next to
orphaning it.

* feat(ssh): record the host-attested shell identity on the lease

A lease names a shell by ptyId, and ptyId alone cannot identify one: a replaced
relay restarts its ids at pty-1, so the same id can name somebody else's shell.
This adds the missing primitive — the incarnation the HOST attested — so a later
change can tell "my shell" from "a different shell wearing its id". No behaviour
changes yet; nothing reads the field.

Only host-attested values are stored. The provider synthesizes a stand-in when a
host reports none; that stand-in is first-write-wins and is dropped when provider
state resets, so the same live shell can present a different one after a
reconnect. Recording that would later read as a different shell and strand a live
pane, so it is refused, and the synthesizer now shares the prefix constant with
the predicate that rejects it.

Leases are rebuilt field by field on load, so the normalizer had to learn the
field too — adding it to the type alone drops it on every boot, which is how a
fence ships silently permitting everything. The oracle reddens on exactly that.

* fix(ssh): fence a recycled relay PTY id on the shell's own identity

A reset relay restarts its ids at pty-1, so an id alone can name somebody else's
shell: a pane still bound to pty-1 could attach to a shell another pane was
already driving, and the two would share keystrokes and output.

The guard that used to catch this compared the paneKey and tabId frozen at spawn.
It was removed for good reason — a pane moved to another tab was refused its own
live shell, and refused as "not found", which read as death and resumed the agent
a second time. So it traded a cross-attach for a double resume.

The incarnation is the shell's OWN identity, so it discriminates a recycled id
without caring where the pane lives: both failures close at once. The relay
refuses a mismatch and is deliberately not worded "not found" — that phrasing is
what the client maps to an expired session, and expiry authorizes a respawn onto
a shell this branch just proved is alive.

Only a host-attested expectation is sent. The locally synthesized stand-in is not
stable across reconnects and would refuse a pane its own shell. An older relay
ignores the field and an older client sends none, so both stay permissive.

The tab-keyed comparator is deleted rather than left dormant, and its suite is
inverted onto the new identity: a recycled id is still refused, and a pane that
moved tabs now attaches instead of being told its shell is gone.

* fix(ssh): only an exit the relay watched may authorize a replacement

A bare not-found became SSH_SESSION_EXPIRED, and expiry authorizes a respawn.
But "the relay I asked cannot hand that id back" proves an exit only if that
relay is the one that minted it — a replaced relay answers exactly this for
shells still running under its predecessor. So after a relay restart, live
orphaned shells were read as dead: ownership cleared, lease expired, and the
agent resumed a second time onto a transcript its first process was still
writing to.

The relay now keeps what it actually observed. On a real exit, and on the
liveness probe that finds a pid gone, it remembers {code, incarnation} in a
bounded map and answers a later attach with SSH_PTY_EXITED instead of throwing
that knowledge away. That is first-hand, same-process evidence, and it is the
only answer that now maps to expiry.

A remembered exit for a DIFFERENT incarnation is not an answer about the caller's
shell, so a recycled id cannot report a stranger's death as its own. A crash
loses the map, which correctly reads as no knowledge rather than as death.

The carrier is the message text: the relay's error transport keeps only a message
and a numeric code, so a structured payload would not survive. It is deliberately
worded to avoid "not found", which older clients map to expiry.

Cost, stated plainly: against a relay too old to remember exits, a genuinely dead
shell is now unproven, so the pane offers the disconnected card instead of
replacing itself. That is the affordance's purpose, and it is the safe direction.

* fix(ssh): a remembered exit answers only the shell that asked for it

Review found three ways the new proof could be believed too easily.

A caller that names no shell was still handed a remembered exit. That is the same
double resume in a new costume: relay A is killed leaving pty-1 alive and
orphaned, relay B mints its own pty-1 and THAT one exits, and a pane carrying no
recorded identity would be told its shell is gone — then replace a process still
running. The expectation must now be present AND match. Panes with nothing to
compare get the disconnected card, which is the direction that cannot lose work.

The proof is also gated on the client declaring it understands it. What the host
answers reaches clients that predate the reply, and one of those reads an
unrecognised attach error as neither death nor recovery — a stranded pane. Older
clients keep the wording they already act on; nothing is lost, because they could
not have used the proof anyway.

And the match is anchored on the whole grammar rather than the token, with the id
and incarnation percent-encoded. A substring test would let any text that merely
quotes the token stand in for the relay's own observation.

Two callers that key on "already gone" now also accept a proven exit, so the
liveness-probe reap does not burn a retry before reaching the same conclusion.

The oracle for the first of these was itself vacuous: `toThrow` with a negated
asymmetric matcher passes whenever anything throws at all. It now reads the
thrown message, and reverting the guard reddens it.

* fix(ssh): the liveness reap proves nothing to a caller who named another shell

The remembered-exit route was fixed to require a present, matching expectation.
The liveness probe is the other route to the same claim, and it still answered
anyone: it fires when a pty EXISTS but its pid is gone, and under a given id a
replacement relay may hold a shell that is not the caller's at all. Its death is
then no evidence about a pane whose own shell may be running orphaned under the
relay this one replaced — and the reply authorizes replacing it.

Both routes now demand the same thing. The reap still happens either way, because
a dead shell should be cleared whoever asked; only the answer depends on whose it
was, and a caller who named a different shell is told exactly that.

Found by asking whether the guard ordering was right, after the first fix closed
only the half that had been reported.

* fix(ssh): the client checks whose exit the proof is about

Enforcement lived only on the host. But the host is the party whose answer is in
question, and mixed versions are the normal state — a host that applies the rule
loosely, or not at all, could hand back an exit for a shell the pane never owned
and the client would replace a process that is still running.

The incarnation travels in the proof precisely so the asking side can check it.
A proof that cannot be tied to the shell this pane asked about is not proof, and
falls through to the disconnected pane. A pane that knows no incarnation cannot
verify anything, so it does not get to act on one either.

Both halves now apply the same rule independently, which is what makes it hold
across versions rather than only when both ends agree.

* fix(ssh): fence the reconnect path too, not just the pane-driven restore

There are two client attach routes and only one was fenced. The pane-driven
restore goes through reattachSshPtySession, which was sending the shell identity;
the relay session's own reattach — the one that reconnects EVERY known pty when a
relay comes back — goes through attachForReconnect, which sent nothing.

That is the wrong one to leave open. A relay coming back is exactly when ids have
been reissued from pty-1, so the main reconnect was the likeliest place to attach
somebody else's shell, and it was attaching by id alone.

It now sends the identity the lease recorded, which is what the lease field added
earlier was for; the two halves finally meet. Pane identity is still not sent —
only the shell's own — so a pane that moved tabs is unaffected. It also declares
exit-proof support, so a proven exit can reach the path that reattaches after a
host restart.

Callers with nothing extra to say keep the older call shape, so this does not
churn every reconnect assertion in the suite over trailing undefineds.

* fix(ssh): actually write the shell identity the reconnect fence reads

The lease field was declared, preserved on load, and read on reconnect — and
never written. Both spawn writers omitted it, so every lease carried only
"pty-N", the reconnect always took the no-expectation path, and the fence added
for it could not fire. The main reconnect went on attaching by id alone, which
is the replaced-relay case the fence exists for.

Worse than inert: a successful unfenced attach durably binds the pane to
whatever answered, so the wrong identity would be recorded and carried forward.

The host attests the identity at spawn and it was already in scope one line
above both writers.

The oracle for this had to pin the WRITE. Every other clause — the type, the
loader, the reader, the reconnect forwarding — was green throughout, because
each was correct in isolation; only nothing joined them. The test that covers
the spawn now seeds a host incarnation and requires it on the persisted lease,
and removing either writer reddens it.

* refactor(ssh): one home for the exit-proof rule, and comments the house style allows

AGENTS.md asks for brief non-obvious comments, one line where possible. Several
of mine ran to five and eight lines of narrative on the relay's attach path,
which is the one place a reviewer most needs to scan the branching quickly. They
now say the same thing shorter.

Both gone-paths in attach() had independently spelled out the rule that proof
must name the caller's own shell. That duplication is what let the earlier fix
close one and miss the other, so the condition is now a single predicate both
ask — a change to the rule cannot reach one path and skip the other.

The renderer kept its own copy of SSH_SESSION_EXPIRED while this branch created
a shared home for exactly that token, whose whole reason for existing is that the
two copies once disagreed about an identity mismatch and the renderer respawned a
live shell. It imports the shared one now.

Not changed, after checking: the per-generation recovery budget still does not
reset on a successful reattach. Resetting it looks obviously right and is wrong —
a flapping PTY alternates failure and success, and a covering test drives exactly
that for forty rounds. The park cooldown already bounds the harm.

* fix(ssh): keep the pane fence for clients that cannot name a shell

Deleting the relay's pane-identity comparison disarmed the recycled-id guard for
every client that has not upgraded. The relay is shared and host-side: one person
updating a host would leave their colleagues attaching by id alone, with nothing
checking it in either direction.

It is back as a FALLBACK, used only when the caller sends no incarnation. A
client that can name the shell is still fenced on that and still attaches after
moving tabs; a client that cannot gets the older, coarser check it was already
living with rather than none at all.

The two clauses inverted when it was deleted are restored, because they send pane
identity with no incarnation — the old-client shape, which should be refused. The
moved-pane property they used to contradict is pinned separately by the clause
that sends an incarnation, and reverting the override reddens it.

* fix(ssh): a bare not-found must not retire a pane's owner

Retiring a stable pane's owner authorizes a replacement that carries the pane's
agent resume payload. Until this branch, an SSH reattach failure reached that
decision as SSH_SESSION_EXPIRED, which the gone-check did not match, so it never
fired for SSH. Retiring the not-found mapping changed that: the raw
`PTY "<id>" not found` now matches, so a replaced relay answering for a shell its
predecessor is still running would retire the owner, fabricate an exit, and
respawn with the resume payload — the double resume, reintroduced by the commit
meant to prevent it.

For an SSH pane the two proving answers are the relay's own observed exit and the
expiry the reattach mints only after verifying that proof names this shell. A
bare not-found is neither, and now propagates instead: the pane keeps its owner
and surfaces as disconnected.

Local and daemon ptys are unchanged — their provider owns its ptys, so absence
really is proof. The shutdown paths that also use the gone-check are untouched:
there, "not found" is the outcome being asked for.

The covering test drove the dangerous shape directly — bare not-found, retire,
respawn with `codex resume …`. It now drives the proof, and the bare not-found
case is pinned beside it.

* fix(ssh): retire a lease the relay proved dead

Reattach failure left every record untouched, including the one answer that
settles it. A shell the relay watched exit kept a live lease, so every later
reconnect fanned out an attach for it — two attempts and a ten-second deadline
each — and the set only grew, in a file written to disk.

A proven exit now retires the record. `terminated`, not `expired`: expiry is the
state the recovery grant reads, and retiring a record must not also authorise a
replacement.

Everything else is unchanged and still leaves the pane detached and recoverable,
which is the point — a not-found is also what a replaced relay answers for shells
its predecessor is still running.

* fix(ipc): one import of the incarnation module, not two

CI's code-quality plugins deny a second import of a module already imported in
the same file; the pre-commit hook runs a different oxlint config and did not
see it. Both failing checks were this: `verify` is a gate that only reported
static analysis, with typecheck, tests and both package jobs already green.

* fix(ssh): harden PTY reattach reliability

* fix(persistence): fence duplicate lease rollback

* test(pty): pin incarnation write fence

* fix(ui): center narrow terminal recovery actions

* fix(ssh): make the unreachable-pane state reachable, and its actions work

The decisive cases for this affordance have sat at `fixme` because the state was
not inducible: the card needs the SSH target CONNECTED while exactly one pane's
attach fails without proving the shell gone, and every host-side fault takes the
whole connection down instead. So the behaviour was only ever argued from
reading, which is how several oracles here ended up green for the wrong reason.

It is inducible now, using the fence this branch added for another purpose: the
relay refuses an attach whose expected incarnation names a different shell, which
is per-pty and leaves the connection healthy. Rewriting a pane's recorded
identity while the app is closed reproduces it deterministically.

Running it immediately found two defects that reading had not:

The error toast painted over the card. It renders at z-50 in the same bottom
strip and was suppressed only for the connection overlay, not for the pane's own
card — so in the one state this affordance exists for, BOTH its actions were
unclickable.

"Start a new terminal" then did nothing at all: no shell, no host change, card
straight back. It routes through the path that resolves the pane's owner and
attaches it, and the owner is the very shell we cannot reach — so the attach
fails and nothing is created. The action now refuses adoption, which is what
makes it a creation. Nothing is killed; the old shell stays alive and unbound.

Honest status: the gate is NOT green. The card now clears and the action runs,
but shell creation is not yet observed reliably across runs. Committing so the
oracle and both fixes are not lost; the remaining failure is the next work.

* fix(ssh): a session id is the instruction to attach, so a refused adoption drops it

Skipping stable-pane owner resolution in main was not enough: the action still
sent the pane's recorded sessionId, and the provider reattaches on that before
any owner logic runs. So "Start a new terminal" kept attaching the very shell it
could not reach, and created nothing.

The id is now dropped at the last gate before the IPC, where it cannot be
reintroduced by a caller that forgets.

Also records what running the gate has established so far, including the one
defect still open: with both fixes in, the click still produces no spawn at all
(visible=true launches=1 shells=1), while the main log over the same window shows
only the pane's own restore retries. The evidence points at the action closure
belonging to a superseded connection, not at the spawn path — so the next step is
to instrument the handler rather than add a third spawn-path guard.

* docs(ssh): locate the remaining defect — main re-derives the session id

Instrumented the handler, the transport and main in one correlated run. The
closure hypothesis was wrong: the handler runs, the connection is live, and the
renderer half is correct — it sends no session id and asks for adoption to be
refused. Main re-derives the id anyway, attaches the unreachable shell, and the
spawn rejects, so the action resolves null and the card returns.

That narrows it from "a renderer race" to one gate in main:
createFreshShellForUnreachablePane covers only the early owner resolution, while
a second site downstream still passes sessionId: owner.ptyId to the provider.

Recorded with the evidence and the shortlist of call sites, plus the instruction
to gate where the owner is CONSUMED rather than adding a third condition at a
third producer — this is the same rule leaking at a third site, which is the
signature this branch keeps producing.

* fix(ssh): refuse adoption where the owner is consumed, not where it is derived

The unreachable pane's "start a new terminal" still attached the shell it could
not reach. The renderer was already correct — instrumenting handler, transport
and main together showed it sending no session id and asking for adoption to be
refused, while main re-derived the id anyway.

The rule had been applied at the two places an owner is PRODUCED and missed at
the one place it is CONSUMED: spawnForStablePane turns an owner into `sessionId`
for the provider, which is what makes an attach an attach. Gating there closes it
for every producer at once.

The decisive E2E now passes: with the target connected and one pane unreachable,
the action creates exactly one shell, leaves the unproven old shell running and
unbound, and clears the card. Reverting the single condition reddens it.

This rule leaked at three sites in a row and each fix was necessary while none
was sufficient. The one that held was placed where the value is used.

* refactor(terminal): the same-id guard now protects a new shell, not an adoption

Refusing adoption removed the case this guard was written for. What remains is
the opposite: a reset relay can reissue the old id to a genuinely NEW shell, and
main has already bound the pane to it — so clearing by that id would unbind the
shell just created. Same code, and it is still needed; the comment said the wrong
reason, which is how the next reader deletes it.

This also closes the planned "tell the user we recovered your terminal" work as
obsolete: there is no silent recovery left to announce, because the action now
always creates.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-13 01:36:01 -07:00
NeilandOrca 971d9548b5 docs(agent-status): make design references self-contained (#13902)
docs/design/agent-status-over-ssh.md was cited from ~10 source files but
does not exist in the repo. Replace each pointer with the invariant the
code actually relies on so the knowledge survives without the doc.

Renderer-side citations (useIpcEvents.ts, agent-status-types.ts) are left
for the concurrent batching change that owns those files.

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

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

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

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

electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
2026-08-11 18:19:43 -07:00
NeilandOrca aa50d8cee3 fix(ssh): release the relay-loss watcher before teardown mux writes (#13737)
* fix(ssh): release the relay-loss watcher before teardown mux writes

Cancelling the in-flight port scan emits rpc.cancel on the control lane.
If that lane is saturated, enqueue admission fails and the writer calls
fail() -> mux.dispose('connection_lost'), which fires the relay-loss
watcher during our own teardown and schedules a redundant relay redeploy.

teardownProviders already released the watcher first, but stopPortScanning
runs ahead of it and is what emits the frame. Hoist the release into a
named method and call it before stopPortScanning on all four teardown
paths.

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

* fix(ssh): hoist the watcher release above abort on every teardown path

Review follow-up. Two nits from the readiness pass:

- The "stop scanning before teardownProviders" comment had drifted onto
  releaseRelayLossWatcher(); move it back above the stopPortScanning()
  call it describes.
- Release the watcher ahead of abortController.abort() too. That signal
  reaches no mux request today, so this is not a live hole, but plumbing
  it into one would have silently reopened the bug on three paths. Making
  the release first keeps the invariant structural rather than incidental.

detachSshPtyConsumerRecovery stays first on the two detach paths, per the
existing synchronous-half-first rule.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-10 21:50:18 -07:00
Neil 0acf04a985 perf(ssh): cancel abandoned remote port scans (#13548) 2026-08-10 20:49:21 -07:00
Brennan Benson d50adec2d2 feat(ai-vault): isolate scanning from terminal workloads (#13411)
* feat(ai-vault): isolate scanning in service processes

* fix(ai-vault): retire idle service processes

* fix(ai-vault): discard unverified cache processes

* fix(ai-vault): clear relay sidecar cancel watchdog on acknowledgement

A cancelled relay call is settled before its 2s cancel watchdog is armed, so the acknowledgement path bailed out of settle() before clearing the timer. The watchdog then faulted a healthy sidecar two seconds after every aborted scan, killing whatever request had since become active.

* fix(ai-vault): clear the pending restart before scheduling another

recordFault overwrote this.timer, stranding a restart that dispose() could no longer cancel.

* refactor(ai-vault): drop the orphaned first-prompt IPC wrapper

session-first-user-prompt-handler.ts now owns this entry point and routes through the service; the copy left in the read module had no callers.

* fix(ai-vault): retry a faulted cold start before surfacing it

A slow first start surfaced a raw 'did not become ready' error to the caller even though the supervisor was already respawning. Requeue an unsent call once onto the scheduled respawn instead.

Also stop arming the cancellation watchdog for a call the child never received: no acknowledgement is coming, so it killed a healthy service and stalled the lane.

Invalidation bookkeeping and ready-waiter construction move to the state module to stay under the max-lines cap.

* fix(ai-vault): give relay title reads their own lane

Before this branch the relay read title files directly, concurrently with scans. Routing both through one sidecar lane put title resolution behind a list scan that may run up to 130s, so SSH tab titles could lag minutes behind.

Split cache and interactive lanes in both the relay client and the sidecar entry, mirroring the desktop service.

Also: clear the ready deadline on fault, so a sidecar that dies before ready cannot fault its healthy replacement five seconds later; retry an unsent call once across a respawn; and skip the cancellation watchdog for a call the sidecar never received.

Restart/circuit bookkeeping moves to its own module, mirroring the desktop policy, to stay under the max-lines cap.

* fix(ai-vault): degrade relay title resolution on sidecar failure

listSessions already returns a host issue when the sidecar is unavailable; titles propagated the raw RPC error instead. Return no titles so callers fall back to preview text, and keep cancellation propagating.

* fix(ai-vault): scrub the service child environment

The children are forked with a 384 MiB heap cap and no loader, but both
spawn sites handed them the full parent environment, so an exported
NODE_OPTIONS silently raised the cap or --require'd code into them.

Allowlist both, following the plugin worker. The desktop child keeps the
eleven agent-root overrides it resolves its own roots from; the relay
sidecar takes remoteHome and hostPlatform from its init message and so
needs none of them. Both children share one priority module while they
share this one.

* fix(ai-vault): soft-disable relay vault when the service is missing

A missing service threw out of the constructor, so a Vault wiring bug
would abort relay startup and take every PTY on the host with it. The
unsupported-platform branch three lines above already treats a Vault
failure as a soft disable; do the same here.

Threading the service through the two handlers instead of a field also
retires the definite-assignment assertion the throw was propping up.

* fix(ai-vault): drain consumed cache invalidations

invalidatedPaths was re-applied in every request's finally and never
drained, so once N paths had been invalidated every later request paid N
evictions for the life of the process; the 4096 cap only bounded how bad
that got.

The re-apply exists to cover a read that overlapped the invalidation, so
drain once nothing is executing. Clearing unconditionally would drop the
re-apply for a request still running on the other lane.

* fix(ai-vault): keep a busy child through slow invalidation acks

invalidate() reused the 5s ready budget as its acknowledgement deadline
and killed the child on expiry, so a delete issued during a large scan
could kill a healthy process mid-scan and burn a slot toward the restart
circuit.

Fault only when nothing is executing. Fork IPC ordering already puts the
invalidation ahead of any later request, so a busy child owes no ack
here, and the 130s/15s request deadlines still catch a wedged one.

The start-retry predicate moves to the state module to stay under the
line cap, matching the shape the relay client already uses.

* fix(ai-vault): report a failed local scan as a host issue

A local-scope scan let its error escape to the renderer, which paints it
over the session list. Service supervision now produces those errors, so
"AI Vault service restart circuit is open." replaced the list.

Route local scope through the degradation the all-hosts leg and every SSH
leg already use, so it lands as a retryable host issue row instead. Same
result shape either way, so no IPC or wire contract changes.

* test(ai-vault): cover the relay restart circuit transitions

The relay policy shipped without tests. Pin both circuit edges, the
aging-out case, the forced-refresh reopen the relay has and the desktop
does not, and the backoff schedule.

* fix(ai-vault): keep the OpenCode roots in the service child env

The scrubbed allowlist dropped XDG_DATA_HOME and OPENCODE_DB, which the child
reads to locate the OpenCode store and database. The pre-PR worker thread
inherited them, so a user who sets either lost every OpenCode session.

* test(ai-vault): anchor the service spawn env assertion
2026-08-10 15:52:59 -07:00
Neil 131010277c [Perf-LH] Serialize relay JSON payloads once per publication (#13516)
* perf(relay): reuse serialized JSON payloads

* Defer bulk relay payload preparation until admission
2026-08-10 01:17:14 -07:00
Neil ced2719b26 refactor: remove unreachable code (#13400) 2026-08-09 18:29:32 -07:00
Brennan Benson 5df2ddbc9c perf(ai-vault): isolate tab title resolution (#13377)
* perf(ai-vault): isolate tab title resolution

* fix(ai-vault): preserve background scan caches

* fix(ai-vault): resolve nested worker from chunks
2026-08-09 16:45:52 -07:00
Brennan Benson a1f61ef8c0 feat(agents): add Prime Agent status hooks (#13384) 2026-08-09 16:45:27 -07:00
Jinwoo Hong 6397668271 Add manual artifact sharing from HTML and Markdown views (#13369) 2026-08-09 16:11:16 -07:00
Jinwoo Hong c991bb27d3 Add account-backed artifact sharing (#13012) 2026-08-07 23:02:29 -07:00
Jinjing 9fb4dbe8eb fix(ssh): handle rejected PTY deliveries with targeted recovery (#12746)
Add targeted recovery for rejected PTY source frames instead of
terminating the relay channel. Classify rejection reasons (malformed,
generation mismatch, range invalid) and attempt recovery based on the
rejection type. Implement admission control at publication time to ensure
frames aren't delivered after ownership changes. Bound recovery attempts
and retry with backoff to prevent exhaustion. Diagnose and log rejection
reasons to aid debugging.
2026-08-05 10:30:04 -07:00
Jinwoo Hong eea0bb64db fix(ssh): make PTY owner admission explicit and non-destructive (#12673)
An owner-capable `pty.openClient` had two failure modes that presented as something else.

If the relay still held an owner record but the request carried no matching resume proof, admission fell through to a SUBSCRIBER grant — a success-shaped response the client cannot use, which it then rejected as "did not grant an authenticated PTY session owner". And if the relay had forgotten the record the client named, admission threw a stale-recovery error, which the client answered by deleting its own recovery row — `clientInstanceId` included — and reopening. Two round trips, and the identity that lets it resume that target at all went with the deletion.

Now every owner grant carries a required `resumed` flag, a forgotten record mints a fresh claim in one round trip, a held claim returns one of three coded refusals, duplicate opens on one connection are rejected even when identical, and an attached-holder refusal becomes a typed error routed through the terminal-relay-error callback instead of feeding redeploy backoff a link that is working fine.

Independent review caught two regressions in the first attempt, both now fixed and both with tests that fail without them:

**A backpressure teardown could take a live owner's session.** The safety argument was that a record only becomes `disconnected` from an observed peer close — but two of the six paths there are capacity paths, where the relay destroys the client's socket itself because its lane queue filled. That is the signature of a client that is ALIVE but not draining fast enough. Demonstrated: the real owner is torn down for backpressure, a rival is granted ownership 270ms into a nominal 30s grace, and the owner's later reconnect with a valid resume proof is refused permanently, backoff cleared, no retry. Closes now carry a cause (`peer-closed` | `local`, defaulting to `local`, which only ever widens a grace), and the floor applies only to closes the transport actually observed on the peer's side. Capacity teardowns, decode faults and sink failures keep the default.

**A client's own zombie connection blocked it permanently.** Only `SshRelaySession` ever requests owner, and every endpoint-credential client shares one principal — so in a normal single-app deployment an `active` incumbent refusing you is almost always your own half-open connection the relay never saw close. That was refused as terminal, where main recovered on bounded backoff once keepalive noticed. The refusal already held both client identities; a match is now a distinct transient refusal that falls through to relay-lost backoff, restoring that recovery. A genuinely different client is still blocked.

Also: each retry deadline now starts when its own phase begins, instead of both being computed at entry where a slow first phase could leave the second with zero attempts.

Fixes STA-3365.
2026-08-05 02:56:07 -07:00
Jinwoo Hong cc1859d61c fix(ssh): snapshot detached SSH leases on quit and bound the teardown (#12687)
Per-target SSH teardown awaited `removeAllForwards` BEFORE anything marked the lease detached, so a slow forward close let the final store flush snapshot while leases still said `attached` — and the later durable write was rejected because persistence had already finalized. On the next launch those leases described a state that never existed.

`beginSshShutdown()` now performs every in-memory transition synchronously before returning, and the quit path calls it immediately before `store.flushAsync()` with no await between. The whole drain shares one deadline that REPORTS unfinished `{targetId, phase}` rather than concluding anything about it, and `waitForSystemSshForwardStop` gained a post-SIGKILL bound.

Nothing here destroys a session. `detached` means this app let go of the lease, not that the shell died — the pre-pass exists precisely so still-running PTYs are recorded as detached-but-alive instead of being lost to an `attached` snapshot. Review confirmed every reader honors that: reattach enumeration and persistence restore filter only `terminated`/`expired`, lease normalization has no age-based expiry, and attempt exhaustion leaves a lease alone. The drain deadline's only consumers are a warning log and a join that discards the value — nothing reads it as "gone".

Review also caught a defect the refactor introduced, now fixed: making the pre-pass synchronous meant a throw from `beginShutdownDetach` — via `webContents.send` on a renderer that quit had already destroyed — escaped the non-async `will-quit` listener and skipped `killAllPty()`, the watchers, `store.flushAsync()`, the teardown barrier and `app.quit()`. That would have lost the exact snapshot this PR exists to make correct. Each call is now wrapped per session, collecting errors and continuing. Proven: the test throws from the first of two sessions and fails without the fix with "Object has been destroyed".

A second test could only fail via timeout rather than assertion; the ordering is corrected so removing the post-SIGKILL bound now fails in 5ms with a clean assertion instead of a 5s timeout.

Rebased onto main and verified independent of #12673 (zero references to its owner-admission changes), which is being reworked separately. Fixes STA-3366.
2026-08-05 02:54:29 -07:00
0f9caf52b1 fix(ssh): time out stalled remote file streams (#11364)
* fix(ssh): time out stalled remote file streams

* test(ssh): cover stream dispose-listener cleanup

* fix(ssh): pause file stream deadlines during sleep

* fix(ssh): replay suspended state to late streams

* fix(ssh): allow slower file stream progress

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
2026-08-05 01:13:13 -07:00
e233d6a641 Recover automatically instead of getting stuck on "Multiplexer disposed" when an SSH relay drops (#12216)
* fix(ssh): recover instead of wedging when the relay channel dies mid-connect

Three coupled defects made a dropped SSH relay look like a permanent bug:

1. SshRelaySession.establish()/reconnect() ran their last liveness gate before
   configureRelayGraceTime(), whose mux.notify() can dispose the mux
   synchronously (writer control-lane admission cap, or a throwing transport).
   The session then latched _state='ready' + _onReady (status bar "connected")
   while watchMuxForRelayLoss() silently no-op'd on the dead mux, so the bounded
   relay backoff in ipc/ssh.ts never ran and the fs/pty/git providers stayed
   registered against a dead multiplexer. Both sites now re-check
   mux.isDisposed() after the notify and take the existing failure path.

2. SshChannelMultiplexer.request()/notifyWithSettlement() always reported the
   permanent-shutdown string 'Multiplexer disposed' with no code, even when the
   recorded dispose reason was connection_lost. The reason is now recorded and a
   shared disposedError() factory serves dispose(), request(),
   notifyWithSettlement(), so a transient drop reports
   'SSH connection lost, reconnecting...' / CONNECTION_LOST. onDispose() on an
   already-disposed mux now fires the handler synchronously with that reason
   instead of returning a silent no-op (without retaining it).

3. TerminalErrorToast no longer renders a transient relay drop in the red
   "please file an issue" style. The marker is matched with includes() because
   the message reaches the toast IPC-wrapped.

ssh-git-response-stream-reader registers its onDispose subscriber after the
abort wiring, since an already-dead mux now fails synchronously there and the
cleanup must be able to drop the caller's abort listener.

Closes #11953

* fix(ssh): treat a mux killed during PTY reattach as relay loss

reconnect()'s post-reattach gate bare-returned when ownsAttempt() went false,
and reattachKnownPtys swallows every per-PTY error, so a control-lane failure
during a large reattach burst disposed the mux without ever reaching the catch:
providers stayed bound to the dead mux, no relay-loss watcher was installed, and
the session wedged in 'reconnecting' until restart. Take the failure path when
our own mux is the one that died so ssh.ts's bounded backoff retries.

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

* fix(ssh): recover when relay dies during setup instead of wedging

Introduce verifyRelayAttempt() to detect mux disposal at each setup phase
(consumer session, home resolution, provider registration, PTY reattach).
Routes mid-setup connection loss into relay-loss recovery instead of
hanging in reconnecting state.

* Extract SSH disposal error factory

Multiple sites were duplicating the disposal error creation logic with
specific message and code values. The renderer uses these to distinguish
temporary disconnects (show reconnection overlay) from permanent shutdown
(show error toast), so all producers must use the same factory to avoid
silent UI degradation.

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-08-05 00:13:41 -07:00
Jinwoo Hong 1f86f980c5 fix(ssh): let terminate reach remote PTYs the app gave up reattaching (#12642)
Every SSH reattach failure abandons the remote terminal without shutting it down, and abandoned terminals then became structurally unreachable — excluded from reattach enumeration and, critically, filtered out of the user-facing "Terminate sessions" action, so a user could not kill them even manually.

The core problem is a naming trap: `expired` never meant the remote shell died. It means the app gave up reattaching. It is written on reattach failure, on spawn-time expiry, and in bulk by a relay reset inside a `finally` that runs even when the force-stop threw. So the leases most likely to name a still-live orphan were exactly the ones the terminate path excluded.

This change is reachability only. Expired leases are now reachable by an explicit user-initiated terminate, with the relay's response used as evidence: a shutdown that reports the PTY gone tombstones the lease, and leases already proven terminated are left alone.

**No automatic kills were added.** Every abandon path was enumerated and none of them proves abandonment: attempts-exhausted knows nothing (the relay never answered), identity mismatch means a *live* PTY belongs to a different pane so killing it would destroy someone else's terminal, and not-found is the one branch with real proof of death — where the process is already gone and needs no shutdown. Per the rule that unprovable liveness never authorizes destroying a session, the abandon paths deliberately leave the process running.

Relay-side automatic collection of unattached PTYs is deliberately NOT implemented: the relay cannot distinguish an abandoned terminal from a deliberately detached one, and the unlimited default grace exists precisely so long-running work survives disconnects and host sleep. Any bounded reaper would be killing on absence of evidence.

Verified: 3 tests red on main. Negative tests assert each abandon path leaves the shell running and the lease terminable, proven real by mutation — adding a shutdown to the exhausted branch or expiring on identity mismatch each turns them red.

Fixes STA-3376.
2026-08-04 23:07:34 -07:00
Brennan Benson 39c3c58d55 perf(runtime): gate terminal.list visual layouts (#12450)
* perf(runtime): gate terminal.list visual layouts and stop the false writable claim

visualLayouts is ~31% of a large terminal.list payload (44,208 B of 137,412 B on a live 134-terminal remote runtime) and has exactly one consumer: the human-readable CLI formatter. Gate it behind an includeVisualLayouts request param that defaults to included, so pre-flag clients are unaffected, and have every --json/internal caller opt out.

Also drop the record-backed builder's writable, which was a verbatim copy of connected. terminal.show now states writability explicitly as exactly what terminal.send's PTY gate enforces.

* test(runtime): type the payload-size fixture arrays for tsc

* fix(runtime): preserve terminal list compatibility

* test(runtime): guard terminal list optimization

* fix(cli): preserve agent access to terminal layouts
2026-08-04 17:50:52 -07:00
847c8c852d fix(agent-status): correlate manual Claude compact hooks (#12332)
Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 15:03:57 -07:00
Jinjing 00867f06e2 fix(ssh): handle owner displacement and graceful shutdown (#12367)
* fix(ssh): handle owner displacement and graceful shutdown

SSH connections can reconnect with valid session proof after network loss or
device sleep. When the incumbent owner is still half-open, allow the
reconnecting client to displace it outright rather than wait for socket closure
— a window that may never close. Retain displaced deliveries for the new owner
to rotate. During app shutdown, drain SSH sessions without terminating recovery
operations, and retry pending owner grants in case a replacement commits mid-drain.

* fix(ssh): handle owner displacement and graceful shutdown

Make QuitTeardownStartGate a shared singleton so SSH connects use the
same shutdown fence as the main quit path. Track test-connection probes
to ensure they complete before final teardown. Guard owner displacement
to prevent stale owners from clearing recovery state claimed by newer
owners.

* fix(ssh): fix flaky test sync and add error code safety check

Test was using tick-based Promise.resolve() loops which don't guarantee
the async operation has started. Replace with signal-based synchronization
that waits for the actual lease flush. Also add nullish-coalescing to
error code check to prevent crashes if error is null or undefined.

* fix(ssh): fence reset transport opens during shutdown

* fix(ssh): keep recovery leases stable across reconnects

* fix(ssh): close transports owned by cancelled connect attempts

When a connect is cancelled after its transport has opened, that cancelled
attempt still owns the transport and must close it — otherwise it leaks. Add
disconnectConnection() to close by identity (not by target ID) so a cancelled
attempt closes only the transport it minted, without tearing down its
replacement's live transport. Track priorConnection to detect whether this
attempt opened a new transport or reused an existing one, and close only on
abandonment if this attempt owns the session.

* fix(ssh): fence old owner proofs and close superseded transports

When an owner reconnects with a new proof while an old one is still live,
the old proof is now fenced with SUPERSEDED_ERROR instead of retrying
indefinitely. The relay also closes stale transports to signal that their
recovery generation has been overtaken by a newer one.

This ensures overlapping reconnect scenarios complete with the newest proof
rather than getting blocked by stale recovery attempts.

* fix(relay): re-pin stdin/stdout fds after closing to prevent recycling

When the relay closes stdin/stdout to signal EOF to the SSH peer, the OS
can recycle those fds (0 and 1) for new sockets or files. If Node still
treats process.stdin/stdout as those numbers, subsequent operations
corrupt socket clients and trigger shutdown errors. Re-pin the fds by
opening /dev/null to keep them occupied and prevent recycling.
2026-08-04 01:37:25 -07:00
Jinjing 637c7e94c9 Add SSH config host picker to add-host dialog (#12334)
* feat(ssh): add SSH config host picker for add-host form

Users can now click 'Fill from ~/.ssh/config…' to browse available SSH
config hosts in a picker, select one, and have the form automatically
prefill with resolved connection details (hostname, port, username, auth).

Previously, an 'import' button provided bulk sync on this form—confusing
and unhelpful when everything was already synced. That action is now
available as a secondary 'Add all' option in the picker.

* fix(ssh): import filter preservation and label fallback

- Reuse search loader on import completion to preserve active filter inside generation guard
- Fall back to hostname when manual host has no label, not empty string
- Make alias duplicate detection case-insensitive to match config picker behavior
- Validate host availability when restoring project group selection
- Add aria-selected attribute to picker options for accessibility

* fix(ssh): harden config picker import, alias folding, and host targeting

Review findings on the ~/.ssh/config picker + bulk add:

- Guard config-host resolution with a generation counter so a late resolve
  cannot overwrite a later pick or a form the user backed out of; freeze the
  other rows while a pick resolves.
- Stop "Add all N" from re-adopting deleted hosts — it now imports without
  reAdopt, matching the new-host count it advertises. Settings → Import keeps
  the explicit re-adopt path.
- Fold SSH aliases through a shared normalizeSshConfigAlias for import
  ownership, delete tombstones, reclaim, picker search, and the save-time
  duplicate check, which now occupies configHost *and* label like the picker.
- Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not
  when `ssh -G` merely echoes the /etc/ssh system default.
- Fail closed with unavailable/setup-not-found when an explicit
  projectHostSetupId names a non-actionable host instead of silently creating
  the workspace on a sibling host.
- Cache the parsed config for the picker session (refresh on open/retry) so
  filter keystrokes no longer reparse and Include-expand the file, keep the
  filter usable during loads, add a Retry on load errors, explain an empty
  Identity file after a config fill, and drop the always-false aria-selected.

* refactor(ssh): centralize host result limit and extract folder group val

Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message
cannot drift from the host's query limit. Extract findActionableFolderProjectGroup
to avoid repeating the folder-host-availability check across the composer hook.

* fix(ssh): pass -F to ssh -G when HOME differs from passwd home

In E2E tests and sandboxes, isolated HOME can differ from the system
passwd home. OpenSSH resolves the default config via getpwuid (passwd),
while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F
to explicitly specify the config path when they diverge, so ssh -G and
the picker resolve the same file.

* fix(ssh): verify config host exists before resolving with ssh -G

When a user edits ~/.ssh/config and removes a host, the import picker
should not fall back to ssh -G's echoed response (which treats any alias
as valid). Check the reloaded config file before resolving.

- Force reload config on each resolve to catch user edits post-open
- Reject aliases not in the current config before calling ssh -G
- Add test for deleted alias edge case
- Fix workspace-target fallback to honor explicit host selection

* fix(ssh): let tombstoned aliases be re-picked in the config picker

Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
2026-08-03 17:32:13 -07:00
JinjingandOrca d7fe9d6bcc fix(ai-vault): support session scanning in SSH worktrees (#11004)
* fix(ai-vault): support session scanning in SSH worktrees

Add relay-native aiVault.listSessions scanning that discovers agent
sessions on SSH hosts. Includes fallback to filesystem crawl for
legacy relays, full cancellation support, result validation, and
scan coalescing to reduce redundant work.

* fix(ai-vault): scan sessions in SSH worktrees with coordinated cancellat

- Extract batching logic to `mapRemoteScanBatches` for reuse and proper cancellation checkpoints
- Move `AiVaultScanCoordinator` from relay to main to handle concurrent same-key requests with individual cancellation signals
- Report scope path truncation consistently across relay and SSH fallback paths
- Gracefully degrade relay handler on unsupported platforms instead of aborting startup
- Refactor issue display to separate blocking errors, scope notices, and skipped transcript counts

* fix(ai-vault): stabilize SSH session scan CI

Swallow async WSL relay stdin EPIPE so the live hook-relay shard no longer
fails after all tests pass. Merge main, resolve scan/relay conflicts, and
align cancellation/host-issue reporting with IPC expectations.

* fix(ai-vault): harden session scan cancellation, relay timeouts, and preemption

Thread the abort signal through every scan and parse path so superseded or
cancelled scans stop promptly instead of parsing every remaining transcript
for a caller that already left.  Replace the fragile message-text relay
timeout check with a typed error code so unrelated errors carrying the
phrase "timed out after" no longer suppress the filesystem fallback.  Fix
scan coordinator preemption so a forced Refresh in one window no longer
re-enters as a spurious cancellation in another.  Add a host-leg cache for
the all-hosts view and cap filesystem concurrency so a single slow remote
home cannot stall the whole merge.

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

* fix(ai-vault): use stable React keys for scan issue banners

Drop array-index keys so react-doctor/no-array-index-as-key passes.
Uniqueness comes from host, kind, agent, path, and message.

* fix(ai-vault): SSH session scanning with configurable depth limits

Implement depth-aware caching and proper scan boundaries to make SSH session
scanning reliable in worktrees. Users can now select between faster (250
sessions) and comprehensive (unlimited) history scans. The scanner:
- Deduplicates scans across relay, host leg, runtime, and renderer layers
- Reuses larger scans to serve smaller depth requests
- Properly bounds in-scope discovery per-limit
- Fixes timeout enforcement when SSH providers ignore abort signals

* Move sessionLimit ref update to useLayoutEffect

Keep render pure for React Doctor by deferring ref updates to
a layout effect, which still executes before render-dependent
effects that consume the ref.

* fix(adhoc): stamp version prefix from main, not the feature branch

Adhoc builds check out arbitrary refs whose package.json often lags
version bumps (e.g. 1.4.165-rc.0 while main is 1.4.168-rc.1). Hourly
always builds main so it already tracks the product line; adhoc now
resolves the base version from origin/main (or ORCA_ADHOC_BASE_VERSION)
so branch builds share that prefix.

* Revert "fix(adhoc): stamp version prefix from main, not the feature branch"

This reverts commit a26a18eb3fd83f7e7d2db9a6a7c3e02e0f79089a.

* fix(ai-vault): fix scoped backfill and coordinator race conditions

Resolve race where the last waiter leaving could abort an already-settled scan (add `settled` flag). Redesign scoped session backfill to keep searching through newer files until the scope reaches its requested session quota instead of stopping at the candidate limit; out-of-scope files no longer consume the scope budget. Centralize scan limit normalization and fix error classification for cancelled scans using the proper helper instead of checking Error.name. Disambiguate cache keys using JSON and add cancellation check after scope discovery phase.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 16:17:00 -07:00
Jinjing 031115b0a5 test(ssh): freeze FrameDecoder clock in framing unit tests (#12356)
Default 4ms maxTurnMs can defer later frames via setImmediate under
CI load, so multi-frame assertions after a single feed were flaky.
2026-08-03 15:01:26 -07:00
NeilandOrca 1dbf55e4df Stop reporting supported Linux hosts as an unsupported remote platform (#12209)
Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:50:20 -07:00
Jinjing a000839465 Add first prompt to agent session history rows (#12085)
* Add first user prompt to AI Vault session history rows

Re-parse transcripts on demand to extract and display the untruncated first
user prompt for copy/reuse. List scans omit the body (payload/perf); UI loads
it when session details expand. Grok sessions extract the typed ask from
<user_query> envelope, skipping injected <user_info> bootstrap rows. Supports
Claude, Codex, Grok, and OpenCode agents.

* fix(ai-vault): split SessionTime out to pass max-lines lint

AiVaultSessionDetails exceeded the 400-line oxlint limit after adding
first-prompt UI; move SessionTime into its own module.

* fix(ai-vault): handle corrupt transcripts and fix OpenCode prompt captur

Corrupt transcripts now resolve null instead of rejecting the IPC call, matching behavior for other unavailable cases. OpenCode SQLite parsing now correctly captures all text parts from the earliest user message only, fixing truncation of large prompts and padding of small ones. Add stale-response guard in the UI to prevent late results from overwriting the current session when tabs switch. Consolidate text slicing via `sliceAtCodeUnitLimit` to avoid surrogate-pair splits across all callers.

* test(ai-vault): add first-user-prompt UTF-16 safety tests

Ensure truncation at safety limits doesn't split UTF-16 surrogate pairs,
preventing corruption of astral characters in captured prompts.

* fix(ai-vault): key first-prompt-card by session.id

Remounting the card on session switches prevents late responses from
a previous load from writing stale data into the component's refs.
Also improves conversation-turn key stability.

* fix(ai-vault): preserve first prompt after preview truncation

* refactor(ai-vault): improve first user prompt capture robustness and per

- Add 15s timeout to full-prompt load to prevent indefinite loading states
- Extract seedFullFirstUserPrompt helper for reuse across parsers
- Prevent AI-generated summaries from becoming the copyable first prompt
- Fix truncation detection in OpenCode SQLite by probing for N+1 rows
- Optimize text bounding to apply safety limit before toLowerCase
- Gate synthetic OpenCode path detection on agent type, not just # presence
- Add test coverage for remote execution host handling

* Fix FirstPromptCard loading state stranded by stale promise reuse

Clears loadPromiseRef during cleanup to prevent the dedupe handle from
causing StrictMode remounts to await stale in-flight requests. Stops loading
when session becomes non-loadable mid-request. Adds tests for StrictMode
double-invoke resolution and main-process timeout scenarios.

* refactor(ai-vault): split session parsers into modular files

Split secondary-parsers into individual files per agent type (copilot,
cursor, hermes, opencode) for improved modularity. Add test coverage
for first-user-prompt envelope handling: unwrap user_query tags and
reject bare user_info dumps.

* fix(ci): clear max-lines and flaky portal readiness check

Collapse an accidental multi-line regex wrap in ssh-connection-utils that
pushed counted lines to 301. Harden the latched-readiness test's ready
transition so CI load can re-observe attach after MutationObserver gaps.

* fix(ssh): extract proxy command helpers to pass max-lines

Move resolveEffectiveProxy/spawnProxyCommand out of ssh-connection-utils
so oxfmt line wrapping cannot push that file over the 300-line lint cap.

* capture first user prompt by ordering OpenCode messages by creation time

- Add `readOpenCodeMessagesInOrder` to rebuild transcript by timestamp, handling
  corrupt/partial files gracefully instead of discarding sessions
- Extract SSH proxy command tests to dedicated file; add backpressure handling
  and stderr draining to prevent proxy process stalls
- On Windows, reject unsafe characters in ProxyCommand values instead of
  pretending to escape them; properly format cmd.exe invocation with verbatim
  arguments
- Expand ProxyJump chains into -J plus final hop, mirroring OpenSSH behavior
- Decouple portal readiness reapply budget from flip-count budget via explicit
  constant
2026-08-02 18:45:55 -07:00
Neil 673d7ca926 refactor(relay): collapse the duplicated FrameDecoder into one shared module (#12078)
src/relay/relay-frame-decoder.ts and src/main/ssh/relay-frame-decoder.ts
were 264 identical lines apart from one default: the relay logs decode
faults to stderr when no handler is supplied, the SSH side stays silent.
Two copies of framing logic is exactly where a wire-format fix lands in one
and not the other.

The decoder's contract and buffer already live in src/shared, so the class
joins them there. The relay keeps a thin subclass that supplies its stderr
default, preserving behaviour for the call sites that omit onError. The
SSH copy is deleted and relay-protocol.ts points at shared directly.

Verified: pnpm typecheck, 102 tests across the 9 framing/backpressure/
handshake suites, and `pnpm build:relay` for all six platform targets plus
the WSL hook relay — the standalone bundle has no new dependencies.
2026-08-02 00:58:10 -07:00
NeilandOrca 73c5009b82 chore(dead-code): drop ~2k lines of unreachable exports and orphan modules (#12077)
* chore(dead-code): drop 2k lines of unreachable exports and orphan modules

Ran knip across every build entry (main, preload, renderer, popout, web,
cli, relay, workers, forked sidecars, config scripts) and removed what no
entry graph can reach.

- 11 orphan modules nothing imported, plus one test that only covered them
- 159 unused exports/types, with their now-dead helpers, imports and tests

Each candidate was verified against dynamic references before deletion.
42 knip hits were false positives and are kept: shared modules consumed by
the mobile/ workspace, the src/shared/plugins/** public API, vendored
shadcn primitives, and relay wire-protocol constants held for compatibility.

Adds knip.json + `pnpm audit:dead-code` so this stays measurable.

Verified: pnpm typecheck, pnpm lint, and 2081 tests across the 73 affected
test files all pass.

* chore(dead-code): move knip config under config/

Root-level additions are blocked by the root directory guard.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-02 00:33:57 -07:00
Jinjing 25fefa4072 fix(P1-A): async SSH consumer-recovery persistence and detach on failed connect (#12026)
* fix(P1-A): persist SSH consumer recovery without a sync store flush

rememberPtyConsumerRecovery ran on the live establish/reconnect path and
called flushOrThrow -> writeToDiskSync, parking the Electron main thread on
the profile-directory write. On a stalled or slow profile mount that freezes
the whole app during SSH recovery and reconnect.

Add Store.flushAsync(): same debounce-cancel and write serialization as
flushOrThrow, but awaits writeToDiskAsync instead of blocking. The consumer
recovery upsert/remove pair is now async and awaits it, and the SSH callers
await through to establish()/reconnect() so ownership is still durable before
relay setup continues. In-memory state still mutates synchronously (before the
first await), so no caller can observe a torn record and dispose() stays
synchronous.

* fix(P1-A): detach the SSH session when a connect attempt fails

Both failure exits in doConnect dropped the session from activeSessions
without calling detach(). claimSshPtyConsumerRecovery only reuses an existing
in-memory entry when detached === true, so the next connect attempt fell
through to minting a fresh clientInstanceId, discarding the remembered owner
lease and its resume identity.

Route both exits through abandonFailedSshSession(), which detaches (keeping
PTY ownership, unlike dispose()) before removing the session, and tolerates a
teardown throw so it can't mask the connect error being rethrown.

* fix(P1-A): await async lease persistence in SSH relay teardown

Failed connect attempts now wait for 'detached' leases to persist before
throwing, preventing reconnects from claiming them before cleanup completes.
Detach and dispose operations are now async and await store durability.

* fix(ssh): make session detach lease writes retryable on failure

Separate in-memory detach (identity recovery, provider cleanup) from lease
write persistence so rejected writes can be re-issued without re-running
provider teardown or re-minting the session identity. Introduce
flushDurableStateOrThrowAsync to flush only SSH-recovery state on the
live establish/reconnect path, avoiding snapshot writes of sidecars that
belong to quit/startup. Use Promise.allSettled in test reset to prevent
one rejected disposal from leaking state into the next test.

* fix(ssh): dispose mux on failed establish and propagate sync errors

- Dispose mux when session is disposed during establish to prevent resource leak
- Propagate synchronous errors in teardown via the completion promise instead of leaving completion undefined
- Add test coverage for terminated PTYs that exit mid-reattach and must stay dead
2026-08-01 23:16:24 -07:00
Jinjing ce5b639e03 fix(P1-B): recover SSH targets and remote file watchers after a network drop (#12032)
* fix(P1-B): recover system-SSH targets after a network drop

Two defects stopped a remote workspace auto-recovering after a blip.

runReconnectAttempt classified failures with isTransientError, which only
matches ETIMEDOUT/ECONNREFUSED/ECONNRESET by errno code or literal
substring. The system-SSH transport — the only transport FIDO2 and
ProxyUseFdpass targets can use — reports network failures as OpenSSH
prose ("System SSH connection timed out"), so the ladder published a
permanent 'error' on the first timeout and the target never came back
without a manual reconnect. isTransientReconnectError adds a
network-shaped prose table on top of isTransientError and is used only on
the reconnect path: connect() keeps the narrow classifier so an
unreachable host still fails fast instead of burning five 30s attempts
and five security-key touch prompts. Auth and passphrase failures stay
permanent on both paths.

runReconnectAttempt also had no generation fence, so a superseded attempt
published its cancellation as a permanent error over the winner's live
connection — reachable when a system-transport proc.onExit schedules a
reconnect while an attempt is still in flight. Cancellation now carries a
stable error name, and both connect() and runReconnectAttempt claim their
connectGeneration and stay silent when a newer attempt owns the state.

* fix(P1-B): retry a dropped watcher overflow marker on real capacity

emitWatcherOverflowToClient published the {kind:'overflow'} resync marker
with controlOverflow:'reject'. A full control queue rejects at admission
with no settlement callback, so the marker was silently discarded and the
remote File Explorer stayed stale until some later watcher event happened
to produce another one — for a quiet tree, possibly never.

The emitter now retains a rejected marker per (client, root) and
republishes it when the sink actually frees up. The existing
onLegacyPtyCapacity signal cannot drive that: it is gated on producer
retention, so it stays silent exactly under the dual-queue pressure that
caused the rejection. RelayDispatcher.onClientCapacity is an ungated
per-client capacity signal that fires on every writer settlement and
drain. It lives on the dispatcher rather than the writer so a retained
marker survives setWrite() replacing the primary sink, and setWrite
notifies capacity once afterwards so the marker does not wait on traffic
that may never arrive.

Retention is bounded to one marker per (client, root), released on
settlement and purged on client detach.

* fix(P1-B): address all review findings on SSH network recovery

Fix four issues from code review:

1. **Bug — admitted overflow markers lost on setWrite**: Retain markers when
   settlement fails `ok: false`, not just on admission rejection. Prevents
   desynced filesystem trees after SSH sink replacement.

2. **SSH error classification expanded**: Add missing OpenSSH patterns
   (`ssh_exchange_identification`, `connection closed by remote`) and new
   `isDefiniteSystemSshHostFailure()` classifier.

3. **ControlMaster retry optimization**: Skip second probe when first failure is
   already definite host-level (network timeout, refused, unreachable). Saves
   ~30s per reconnect ladder step.

4. **Overflow flush under dual-queue pressure**: Gate pending marker retries on
   control-lane headroom instead of re-attempting on every capacity notification.
   Reduces thrash proportional to producer traffic.

Add regression tests for marker republish on sink replacement and validate auth
error detection against live OpenSSH credential rejection messages.

* rm random doc

* fix(P1-B): skip credential-failure retries and recover watcher markers o

- Auth and passphrase errors fail immediately without retry attempts
- Bare "System SSH probe failed (exit 255)" is transient only for reconnect
- Watcher markers survive client invalidation when switching SSH connections
- Add network error patterns: "lost connection", "remote end closed"
2026-08-01 22:21:37 -07:00