Merge upstream into fork_main and repair what the merge silently dropped #5
Loading…
Reference in a new issue
No description provided.
Delete branch "merge/upstream-split2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Merges 1053 upstream commits into
fork_main, then repairs what the merge silently dropped.Why this is not a routine merge
Upstream refactored aggressively — monolithic files into module trees,
OrcaRuntimeServiceinto a mixin chain,index.tsintostartup/. A naive merge drops fork-only additions with no conflict: a helper survives but loses its call site, a runner is never installed, a ratchet keeps reading a file whose contents moved. Typecheck stays green and the feature is dead.That happened repeatedly here. Six independent domain reviews of the merged tree, then three adversarial reviews of the repairs, found it each time.
The one that mattered most
setAgentSessionRestoreRunner/setAgentSessionSaveRunnerhad zero call sites after the merge. Every link in the chain still existed — button, IPC, preload, RPC, runtime — andsaveActiveSessionsthrew*_unavailablefor every paired runtime. Nothing was red. There is now a guard for it, verified by deleting an install and watching it fail.Fork features restored
startup/splitpresentationthreaded but never readworkspaceIdmissing from close-actions, panel-items, surfaceisLocalUtilityWorkspaceIdonNewGitControlPlaneTabwiring droppedhideChevronsubmenu opt-outnonLocalEntriesshamefullyHoist.npmrcDeliberately unchanged
Project groups stay local-only. Mid-review I changed them to make remote grouped imports work; an adversarial reviewer proved it created an empty group with none of the repos in it. Fully reverted — the shipped behavior is untouched.
CI
Runs on
nixosrunners via the flake's newcidevShell, which is now the toolchain contract. rpm dropped (nothing consumes it — the flake pins the.deb). Lanes with no runner here are gated off rather than deleted, so their contract tests keep working.package_windowsis forced to skip: it sits inverify.needs, so without this no PR could ever go green.Test failures
Of 29 failing files, 11 are parallel-load flakes that pass in isolation. The rest are upstream tests contradicting fork features (the test gives), fork ratchets whose target moved, or probes assuming an FHS host —
posix-tool-search-pathappends real tool dirs after the FHS ones, a no-op on Debian.Known remaining, all NixOS-runner limitations rather than merge regressions: the fish lane (nixpkgs fish never arms DECSET 2031 — 4.0.2 and 4.8.1 behave identically, so not a version gate), two ZDOTDIR examples, WSL, and bash 5.3 readline echo.
Verification: all four typecheck projects clean, oxlint 0 errors, ~71,400 tests passing.
🤖 Generated with Claude Code
Antigravity was the last agent posting hook status through Windows PowerShell 5.1. Every hook event — roughly one every 2-6s during an active session — paid a ~300ms interpreter cold start, which is what made the console the agent allocates for each hook last long enough to be seen as continuous flashing. Move the Windows POST to the shared curl.exe builder every other agent already uses, via the `extraFormLines` escape hatch for the `hook_event_name` field Antigravity uniquely needs. Measured on Windows 11: 326ms -> 134ms per event. Because the curl line percent-expands its arguments, the script also needs `setlocal DisableDelayedExpansion` (#9358/#9941) so a `!` in a pane key or worktree path is not eaten as a delayed reference. curl omits a `--data-urlencode name@-` field entirely when stdin is empty, so accept an absent or blank Antigravity payload as `{}` at the ingest boundary — the POSIX script substitutes `{}` before posting and PowerShell did the same, and without this a payload-less event lost the status transition its `hook_event_name` still carried. Scoped to that source; every other agent keeps rejecting a body it cannot parse. Adds a cross-agent guard asserting the invariant the original drift violated: a managed Windows .cmd hook posts through fully-qualified curl.exe and spawns no interpreter. Generated under a mocked win32 platform so the POSIX CI legs guard it too. Validated on a real Windows 11 host, not an emulated platform check. Fixes #15117isPtyKnownExited read a PTY record as exited whenever `connected` was false: if (pty) { return !pty.connected } Its own leaf fallback, one line below, demands getTerminalState(leaf) === 'exited', which is only true once lastExitCode is set. So the same function proves absence on one path and infers it on the other, and the inferring path is the one that runs whenever a record exists. onPtyExit is the only writer of lastExitCode. The liveness sweep clears `connected` with no exit code for every PTY behind a dropped relay, so that state is a lost connection to a process that may still be running on the host — not an exit. Reading it as one makes subscribeToPtyExit fire its listener synchronously at subscribe time, which retires the lease and emits `end` for a live terminal. Mobile reads `end` as "PTY gone" and rearms; after three attempts it stops and leaves the composer on "Waiting for terminal…", which is where the 0.0.44 permanent lock comes from. Use the runtime's existing three-valued discriminator so both paths demand the same proof. 'unknown' now keeps watching, and the later real exit still fires the listener; a subscription that outlives its PTY is still bounded by the connection abort. Both callers are in subscribeToPtyExit — the subscribe-time fast path and the registration-race re-check — and both want proven-exited, so neither changes shape.* refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) Orca needs to run code after the user's own zsh startup files. It bought that by keeping ZDOTDIR pointed at its own wrapper dir for the whole of startup and sourcing each user file by hand -- four generated files per transport, with a fake ZDOTDIR live while /etc/zshrc ran. That single decision is the root of a whole bug family: - /etc/zshrc assigns HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history unconditionally, so history landed inside Orca's own dir (#11044), and an epilogue had to repair it. - zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh emulation, so a user .zshenv or .zprofile ending in `emulate sh` hid every later wrapper file. The emulation degrade blocks and their forked $(emulate) probes exist for that. - One wrapper dir shared by two installed builds could mix files from both, so every generated file had to redefine the helpers it called. - The baked generation-time ZDOTDIR literal is unusable when a Windows-generated wrapper is sourced inside WSL via /mnt/c (#8003), so the runtime path had to be re-derived from %x. The wrapper now hands ZDOTDIR back on its first lines and defers Orca's work to a precmd hook that runs at the first prompt -- after .zprofile, /etc/zshrc, .zshrc and .zlogin, every one of which zsh reads from the user's own directory exactly as in an unwrapped shell. Each bug above stops being reachable rather than being repaired, and their machinery goes with them: eight of thirteen exported blocks in shell-templates.ts, both drifted discovery bodies (unifying them closes the "reconciling the two is a follow-up" note the file carried), and the relay's separate ORCA_USER_ZDOTDIR shape. Generated zsh drops from 819 lines across twelve files to 143 in three. Two things the design has to get right, both found by running it rather than reasoning about it: - Every function is defined ABOVE the source of the user's .zshenv. A user file ending in `emulate sh` puts the rest of the wrapper under sh parsing rules, and the first prototype died there with `parse error near '\n'` -- silently, leaving the pane unwrapped. Function bodies are parsed at definition time. - ORCA_ORIG_ZDOTDIR is vetted, not trusted. The launch config only sets it when it resolved a usable dir, but a pane inherits its parent's environment too, so a stale value from an older build can arrive on its own and would point ZDOTDIR back at a wrapper dir. The ownership check Node applies now also runs in the shell, where that route is visible. Orca also stops inventing a ZDOTDIR: where the user has none, ORCA_ORIG_ZDOTDIR is absent and the pane ends with ZDOTDIR unset, as an unwrapped login zsh does. Verified on real zsh over a real PTY -- necessary, because a precmd hook never runs in a shell started with -c, so the existing `zsh -i -c` probes could not have exercised this design at all. src/main/zsh-startup-hook-pty-harness.ts drives the shell to a prompt and reports through a file rather than stdout, which a PTY echoes. * test(shell): cover the relay variant of the zsh hook in a real shell The relay writes its own variant -- no OSC 133, remote CLI bin dir instead of the agent-teams shim -- and it had no live coverage. It used to carry a second ZDOTDIR shape as well, which is how it drifted from the desktop template in the first place; now the spec flags are the only difference, and this pins that. * fix(shell): rebase the single-file hook onto content-addressed wrapper trees #15285 landed content-addressed wrapper roots and a per-transport fileset module while this branch was in flight. The fileset modules are now the single place the tree is described, so 'only .zshenv' is stated once per transport and the required-paths check follows from it rather than repeating the list. * test(shell): point the mixed-build proof at the relay, the one fixed wrapper path #15285 content-addressed the desktop and daemon trees, so two builds can no longer write the same directory there and the scenario this file covers became unreachable on those paths. The relay still writes a fixed ~/.orca-relay/ shell-ready, so that is where the hazard survives and where the proof belongs. * fix(test): make the zsh PTY harness survive a startup that stops to ask Two CI-only failures, both from driving a real PTY where the old probes drove a pipe: - A host whose global zshrc runs `compinit` over directories it considers insecure stops startup and ASKS. A pipe-backed `zsh -i -c` never saw the question; a PTY sits at it until the timeout. The harness now answers it. ZSH_DISABLE_COMPFIX does not help -- that is an oh-my-zsh convention and plain compinit ignores it, which I confirmed by reproducing the prompt locally. - The PS1 line was typed at t=0, so on such a host the question consumed it as its answer. The harness now waits for the shell to fall quiet first, which also stops a slow prompt framework racing the same write. Also merges a duplicate vitest import the native code-quality audit flagged. * fix(test): stop the live-shell assertions assuming macOS host behaviour Two of them hardcoded what my machine does rather than what Orca owes: - LINEINIT was pinned to 'none'. A host whose global zsh config installs its own zle-line-init widget has one either way; the contract is that it looks the same wrapped as unwrapped, which the assertion beside it already states. - The dropped-precmd_functions case asserted HISTFILE was no longer the scoped path. Whether the scoped value survives at all is the host's call: macOS /etc/zshrc overwrites HISTFILE so it does not, and a host with no such assignment keeps whatever the spawn env set. Now compared against an unwrapped pane given the same env, which is the real contract on both. Also notes, where the emulation cases live, that they only discriminate on a host whose system zshrc clobbers HISTFILE -- on CI's Ubuntu the load-bearing assertion is ORCA_HISTFILE having been consumed. * test(shell): re-pin the fixes the four-file wrapper was built for Archaeology over the removed blocks: each existed for a bug, so each needs the bug shown to be unreachable rather than just the code gone. Six restored or added, each naming the change that introduced the behaviour. - #8003, twice: the wrapper sourced from a relocated root, and from a non-ASCII (token-range) one. The old file baked its generation-time path in and had to re-derive the runtime one from %x to avoid using it; this one bakes nothing. Both runs assert ORCA_SHELL_FEATURES came back consumed, so 'the user's .zshrc loaded' cannot pass on a pane that never read the wrapper at all. - #4667: user startup files must see their OWN ZDOTDIR while they run, or plugin and theme lookups resolve into Orca's dir. The old wrapper swapped ZDOTDIR around each source; this one never takes it away, and the values now have to match an unwrapped pane's. - #1947: a user .zshenv that returns early. - #15258: an inherited ZDOTDIR that is an Orca wrapper dir must be refused. CI proved this route is live -- the launch config only sets ORCA_ORIG_ZDOTDIR when it resolved a usable dir, but a pane inherits its parent's environment too. - #11044/#11146: a nested Orca inherits neither cross-process channel and no ZDOTDIR of Orca's, which is what makes #11044's plain shape unreachable rather than repaired. Verified the child-env probe detects a real leak before trusting it to report the absence of one.* fix(worktrees): cover WSL distro history and bound the retirement backfill scan (STA-4472, STA-4473) * fix(worktrees): bound outstanding retirement backfill listings, not just their rate The scan deadline abandons a listing, it cannot cancel one: an unabortable readdir keeps its libuv threadpool thread until the OS releases it. The failure backoff paced retries but never counted the abandoned calls, so a mount that stayed wedged stacked a new stuck thread on every lapse until the four-thread pool starved every other filesystem user in the process. UNC reads were already bounded by the WSL gate's permit accounting; plain SMB/NFS listings were not. Cap the outstanding listings process-wide and serve the memoized failure once the cap is reached. Recovery is preserved: a slot frees as soon as an abandoned listing settles. * fix(worktrees): keep a late retirement listing, and stop deferrals penalising healthy repos Three review findings on the scan bound: - A listing that landed after the 15s deadline had its result discarded. Under the WSL gate that is the common case, not an edge one: the gate admits a single scan at a time and allows it 60s, four times this deadline. The namespace was then left unseeded on exactly the mounts this feature exists to cover. The answer is now kept when it arrives. - A namespace deferred at the outstanding-listing cap never touched the wedged mount, so arming its backoff spread one bad mount's outage to repos on healthy disks. Deferrals no longer memoize; the next create retries as soon as a slot frees. - The cap comment claimed it bounds threadpool starvation. It bounds this module's share only; the WSL gate and its close lane hold threads of their own. Comment corrected. Also stub the WSL resolver in the UNC-root unit test, which otherwise shells out to wsl.exe and boots the developer's distro on a Windows runner. * fix(worktrees): only count stuck retirement listings, and fence the backoff on a monotonic clock Two more review findings on the outstanding-listing cap: - The cap counted every in-flight listing, not just the stuck ones, so it fired on healthy machines. Nested workspaces give each repo its own scan key, so a few first-time backfills are routinely in flight together; the third was rejected and its create then picked a name against an unseeded registry. Only listings that have outlived their deadline occupy a slot now, which is what the cap was always meant to bound — healthy scans finish in milliseconds. - The backoff fence compared wall-clock times. The WSL gate this scan runs under deliberately avoids wall time for exactly this ('would misjudge stuckness across laptop sleep or NTP steps'); a backward step pinned a namespace in its failure memo for the size of the step. Now monotonic. A third finding — that a late listing writing into an entry a retry has replaced loses the answer — was investigated and refuted. An entry is only ever read back through the map, and callers hold the promise rather than the entry, so a write to a replaced entry cannot be observed. No guard added: the test for it passed with and without one. * fix(worktrees): gate retirement rescans per namespace, and keep a partial answer usable Replaces the process-wide listing budget with a per-namespace rule, and stops a refused source throwing away the sources that did read. - A global budget was the wrong shape: one wedged mount spends it on its own retries (lapse, restack, lapse) and then every other namespace — including repos on healthy local disks — is refused for the process lifetime, which is a strictly larger blast radius than the wedge it replaced. A namespace now simply may not start a second listing while its own is still stuck, so a bad mount costs exactly one thread and nothing else is affected. - Rethrowing a gate refusal abandoned the whole scan at the first source. For a WSL repo the UNC workspace root is listed first, so a stuck 9P route also discarded the plain, readable Windows-side bucket scan that needs no distro access — worse than the behaviour before the split. Discovery now returns what it read plus a "complete" flag; the names are used, and only the memoization is withheld so the hole is retried. - An I/O failure was reported as a complete empty listing, so a transient EIO on a redirected or network home memoized "nothing is retired" for the process lifetime. Only ENOENT/ENOTDIR now count as a complete answer. The recovery tests now release the stalled listing rather than leaving it pending forever: a retry while the previous call is still stuck is precisely what stacks threads. * fix(worktrees): keep the scan retryable when a WSL distro home will not resolve Resolving a distro home shells out to wsl.exe, which returns nothing for a stopped or slow distro (the call has a 5s timeout) or when wsl.exe is not resolvable from the Electron process. That case dropped the distro bucket source silently and still reported the scan complete, so the empty answer was memoized for the whole process lifetime. That is the STA-4472 defect re-entering through the back door: the distro is exactly where a WSL workspace's agent history lives, so a workspace whose directory is gone leaves its only surviving evidence unread, and the next generated create reissues that cwd. It does not self-heal either — the scan key is derived from the probe path, which is unchanged by a failed home resolution. An unresolved distro now marks the scan incomplete, which serves the names that were found while leaving the hole to be retried after the backoff. * fix(worktrees): trace a WSL repo's Windows-side workspaces into the distro too Distro discovery keyed only on the workspace root being a UNC path, but a WSL repo can legitimately own workspaces under C:\. computeWorkspaceRootAsync mirrors the workspace dir into the distro only when the distro home resolves at create time; when that wsl.exe call fails it falls back to the drive path, and those workspaces stay on the Windows side. The agent is still spawned through wsl.exe, so its cwd is the drvfs mirror (/mnt/c/...) and its bucket lands in the distro's own ~/.claude/projects, where the host-home scan cannot see it. The scan then reported complete and memoized the empty answer, so the name was reissued and the next occupant inherited the previous conversation — the STA-4472 defect, in the one configuration the UNC check does not cover. Which distro to look in comes from the repo path rather than the workspace root, since that is what still identifies the distro once the root is a drive path. * revert drvfs-mirror discovery, and pin the "no agent state" classification Reverts the previous commit. The drvfs branch traced a WSL repo's Windows-side workspaces into the distro, but its production wiring cannot be pinned: the distro comes from parseWslPath(repo.path), which short-circuits off win32, so no assertion on a Linux or macOS runner can reach it — deleting the wiring line left every test green. Shipping an unpinnable branch is the exact unreached-module shape this PR exists to close, and it is not worth it here: the branch only pays off in a narrow race where getWslHomeAsync fails while the workspace root is computed and then succeeds seconds later during discovery. Whenever the distro home resolves, the root is UNC and the existing path already covers it; whenever it does not, the scan is already reported incomplete and retried. Also adds the missing guard on the other side of the same classification: ENOENT and ENOTDIR mean "no agent state on this machine", which is a complete answer. That is the common case for a fresh or Codex-only install, and misclassifying it as incomplete would turn the one-time seed into a 60s-interval rescan for the life of the process. The expression had no test; it does now. * test(worktrees): make the retirement backoff window a real assertion The test that claimed to cover it settled the stalled listing and re-entered in the same tick, so outstanding cleared only in a later microtask and the no-restack rule answered first. It was a duplicate of the test above it, and the backoff clause it was meant to pin had no coverage at all: deleting the clause left all twelve tests in both files green, so a regression that re-probes a wedged mount on every generated create would have shipped. Flush the microtask so the listing is genuinely settled, then assert both directions — the memo still serves the failure inside the window, and the same call succeeds once the window lapses. * fix(worktrees): stop trusting a UNC ENOENT, which is what a shut-down distro looks like Windows reports an unreachable 9P route as ENOENT, so a distro that has merely been shut down is indistinguishable from one that never held any buckets. wsl.ts already refuses to trust a UNC ENOENT for the same reason, probing inside the distro instead. The classification added earlier called ENOENT a complete answer, which is right for a local home that simply has no agent state but wrong here. After a wsl --shutdown the cached distro home still resolves, so nothing else marked the scan incomplete: the empty result was memoized for the whole process lifetime and every later generated create in that namespace reissued names spent inside the distro. That is STA-4472 again, reached by a different route. ENOENT now only means "absent" off UNC. * fix(worktrees): tell an absent distro directory apart from an unreachable 9P route Distrusting every UNC ENOENT fixed the shut-down-distro hole but overshot: a distro where nobody has run Claude genuinely has no ~/.claude/projects, which is the common case for Codex-only users and for anyone running agents from the Windows side. Those namespaces could never report a complete answer, so the one-time seed became a full rescan every 60s for the life of the process — each one re-spawning wsl.exe and taking the single process-wide scan slot from transcript discovery, on a path that runs on every composer open rather than only at create. Probe the parent instead. If it lists, the child really is absent and the answer is complete; if it does not, the route is down and the scan stays retryable. Both directions are pinned: reverting to either of the previous behaviours turns a test red. * fix(worktrees): walk up to a reachable ancestor, not just one level The reachability probe checked a single parent, which only disambiguates when ~/.claude exists but ~/.claude/projects does not. The far more common shapes have the ancestors missing too: a distro where Claude has never run has no ~/.claude at all, and a repo with no workspaces yet has neither the workspace root nor its parent. In both, the one-level probe also got ENOENT and called the route unreachable, which is exactly the 60s rescan loop it was added to prevent. Walk up until a listing succeeds, bounded so a pathological path cannot hold the scan slot. One reachable ancestor proves the route is up, so the ENOENT below it is real absence. The test that was supposed to guard this had ~/.claude resolving, so the real shape was never exercised — which is why the defect shipped green. Its fixture now leaves the whole chain absent up to the distro home, and reverting to the one-level probe turns it red. * docs(worktrees): record what gating retirement listings costs The shared WSL filesystem gate admits one scan task process-wide, and its stuck-task check matches scan against scan regardless of route. So retirement discovery now queues with — and on a wedged distro can fast-fail — native-chat transcript discovery, which the ungated readdir it replaced never could. Gating is still right: the gate holds the only deadline and permit accounting these UNC reads get, and without it a hung 9P route keeps a libuv thread outright. A dedicated lane would need a third priority, which is a change to the gate rather than to this file. Writing the trade-off down so the next reader does not have to rediscover it. * perf(worktrees): probe reachability with stat, and record that the gate coupling runs both ways The ancestor probe only asks whether a directory is there, but it listed it — enumerating a WSL home over 9P, on the composer-open path, holding the single scan permit while it did. stat answers the same question; the gate already supports the operation. Also corrects the trade-off note added last commit, which recorded only the direction where retirement discovery is the victim. Because the gate stuck-check matches scan against scan regardless of route, the reverse is now true too and is the part this PR introduces: a retirement listing wedged on one distro can fast-fail transcript discovery on a healthy one. * fix(worktrees): drop imports the discovery extraction left unused The rebase onto main kept main's import block, which still pulled readdir and homedir for discovery code this branch moved into worktree-retirement-discovery.ts. * fix(worktrees): drop the last import the discovery extraction left unused* Increase shell readiness timeout to match daemon barrier Slow interactive rc files can take longer than 1.5s to initialize. Raise the startup command readiness timeout from 1.5s to 15s to match the daemon barrier and prevent queued commands from executing mid-startup. * fix(terminal): keep a quick command queued until its own spawn takes it (STA-4876) Triggering a quick command opened a terminal tab titled with the command's label, the shell started and drew its prompt, and the command never ran. TerminalPane snapshots `pendingStartupByTabId[tabId]` in a useState lazy initializer, and a mount effect deleted the entry immediately. The pane's key is `${tab.id}-${tab.generation ?? 0}`, so anything that bumps generation before the command reaches a shell — the stall-recovery remount fired from `requestTerminalPaneRecovery`, or the allDead activation regeneration — mounted a second pane that re-read an emptied slot and spawned with no command at all. The loss was permanent, which is why every scope failed alike: repo, global and agent-prompt all funnel through the same queue-then-snapshot sequence. Spend the entry at `onPtySpawn` instead, which is the one point that proves this pane's own fresh spawn exists. A pane retired mid-connect never reaches it, so the command stays queued for the next mount; reattach skips `onPtySpawn`, so it cannot spend a command it never delivers. Three details are load-bearing: - Ownership is reference identity (`paneOwnsQueuedStartup`). Setup and issue splits borrow the same `deps.startup` field for their own one-shot payload, and that payload can be structurally identical to the queued command, so a truthiness test would let a split pane spend a command it never runs. - The consume runs after `bindActivePanePty`. While the tab still has no ptyId, the queued entry is the only thing holding its worktree out of the retention-budget force-park, so dropping it first unmounts the pane mid-spawn. - The callback is one-shot. `onPtySpawn` fires on every fresh spawn a pane makes, including hibernation wake and the respawn ladder, and a command queued after the first launch belongs to that later launch. Known residual, documented at the call site: the consume tracks "a pty exists", not "the command ran". Windows embeds short commands in the shell argv, so they execute before the spawn resolves and a pane retired in that window re-delivers on remount; on POSIX the write waits for shell-ready, so a pty that dies in that window loses a command already spent. Closing either needs a delivery signal from main rather than this callback. Both windows are narrow, and both are strictly better than losing the command unconditionally. * fix(terminal): guard the queued-startup wiring the review found untested Follow-ups from the final review pass on this branch. - Collapse the ownership + one-shot decision into `createQueuedStartupConsumer` so the call site is a single call rather than inline logic no test could reach. Two mutants survived the whole suite before this: relaxing ownership to a truthiness check, and dropping the one-shot guard. Both now fail. - Rewrite the throwing-consume test. It asserted `updateTabPtyId` had been called, which runs *before* the callback, so it passed with the try/catch deleted. It now asserts the throw does not escape into the connect promise, which is the invariant the try/catch actually provides. - Correct the `onQueuedStartupSpawned` docblock. It claimed the callback is "the first moment the command is guaranteed to reach a shell"; the diff's own caveat says otherwise, since Windows runs an argv-embedded command before this fires and a POSIX shell can die before the shell-ready write. It marks a live shell, not delivery. No behavior change: the consumer is the same predicate and the same one-shot, moved behind one exported seam. * fix(terminal): roll shell wrapper isolation into a fresh daemon * Revert "Increase shell readiness timeout to match daemon barrier" This reverts commit 6ab273ef362be4056c16c2caec715fe794532fbf. * Condense queued startup spawn comment Simplify the multi-paragraph explanation into a concise summary that captures the key points: spawn must wait until after the pane is bound to preserve the worktree from force-parking, and the behavior differs between POSIX and Windows for delivery timing. * fix(terminal): prevent consuming replaced queued startup commands When a queued startup is replaced before the pane's first spawn, the one-shot guard alone still allows consuming the replacement. Add isStillQueued callback to verify the slot still holds the originally captured command (STA-4876). --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>Five decisions have to be made on each `wsl.exe` call. Each has a right answer, each is invisible in a diff, and each has shipped wrong: - **Separator.** `--` makes wsl.exe expand `$name` in every forwarded argument before the guest runs -- even with no shell in the command -- so `awk '{print $2}'` loses its field reference (#12964). - **Shell.** A login shell on a probe path sources `~/.profile`, so one blocking line eats the whole timeout (#14288) and every call pays startup (#9768). No login shell on a user-facing path means PATH does not match the user's own terminal, so nvm-installed agents read as absent (#9725, #7563, #8366). - **Fencing.** An interactive login shell runs the distro rc, and stock Ubuntu writes its "run as administrator" hint to *stdout* -- so anything parsing that stream reads the banner as data (#11327, #11823). - **WSLENV.** Unset, a Windows-side variable silently never crosses (#12557). - **Payload.** Scripts go in on stdin. A script on stdin has no quoting boundary to escape from, which is what the base64 and `eval` wrappers work around (#14292). `filesystem-watcher-wsl.ts` already does this and is the only WSL caller with no quoting bug in its history. `runWslProcess` makes them once, on top of W1's `runProcess` so it inherits windowsHide, shell:false, timeouts and abort. `lane` is required with no default: picking the wrong lane by omission is the most common WSL defect here. The probe lane resolves the login PATH/HOME once per distro and then runs with no shell at all, so #14288 and #9768 are closed by construction rather than by a longer timeout. An unprobed distro degrades to the interactive lane -- "we could not ask" must not become "run with no PATH". Additive only: no call site is migrated yet. The new guard allowlists the 23 files that still spawn directly, and its length is the workstream's goalpost. Two guard bugs found by testing the guards against planted call sites: a bare `main/wsl` prefix also exempted `main/wsl.ts`, `wsl-availability.ts` and `wsl-unc-delete.ts` -- three real offenders.* fix(automations): localize schedule weekday names and labels The Weekly Day picker rendered a hardcoded English tuple, and shared schedule labels built copy as `${day}s at ${time}` from an OS-locale Intl weekday, so a non-English UI showed Sunday…Saturday (or 星期五s). Shared now emits deterministic English (the CLI contract) plus a locale-free AutomationScheduleDescriptor; the renderer formats that descriptor through translate() with Intl/CLDR weekday names resolved from getIntlLocale(). Fixes #14404. * test(automations): assert localized weekday copy in rendered DOM The existing coverage walked the React element tree, so nothing proved the Day dropdown and cron status row reach the DOM localized. Mount the picker under happy-dom with the Radix Select swapped for a native <select> (the pattern RepositoryWorktreeDefaultsSection.test.tsx already uses, since Radix portals its content only once opened) and read real option text. Also key the weekday SelectItems by index rather than by translated copy, so a runtime language switch reconciles instead of remounting all seven items. * fix(automations): keep the weekday SelectItem key off the array index react-doctor(no-array-index-as-key) rejects `key={index}`; the localized weekday name is already unique per locale, so keep it as the key. * fix(automations): match the real AutomationDraft shape in the render test The fixture invented `repoId`/`branchMode`/`enabled` fields; runtime ignored them but `tsc` did not. Mirror AutomationSchedulePicker.test.ts's fixture. * fix(automations): localize the custom-cron field chips The five cron field headers rendered one row above the status row this PR localizes were still hardcoded English, so a Chinese UI showed Minute/Hour/Day/Month/Weekday. Same defect shape as the deleted DAY_OPTIONS array. Chip keys move to stable field ids so a locale that renders two fields with the same word cannot collide, and the truncated header carries a title so longer copy (es 'Dia de la semana') stays readable. * fix(automations): keep weekday option keys stable --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>* fix(automations): localize schedule weekday names and labels The Weekly Day picker rendered a hardcoded English tuple, and shared schedule labels built copy as `${day}s at ${time}` from an OS-locale Intl weekday, so a non-English UI showed Sunday…Saturday (or 星期五s). Shared now emits deterministic English (the CLI contract) plus a locale-free AutomationScheduleDescriptor; the renderer formats that descriptor through translate() with Intl/CLDR weekday names resolved from getIntlLocale(). Fixes #14404. * test(automations): assert localized weekday copy in rendered DOM The existing coverage walked the React element tree, so nothing proved the Day dropdown and cron status row reach the DOM localized. Mount the picker under happy-dom with the Radix Select swapped for a native <select> (the pattern RepositoryWorktreeDefaultsSection.test.tsx already uses, since Radix portals its content only once opened) and read real option text. Also key the weekday SelectItems by index rather than by translated copy, so a runtime language switch reconciles instead of remounting all seven items. * fix(automations): keep the weekday SelectItem key off the array index react-doctor(no-array-index-as-key) rejects `key={index}`; the localized weekday name is already unique per locale, so keep it as the key. * fix(automations): match the real AutomationDraft shape in the render test The fixture invented `repoId`/`branchMode`/`enabled` fields; runtime ignored them but `tsc` did not. Mirror AutomationSchedulePicker.test.ts's fixture. * fix(automations): localize the custom-cron field chips The five cron field headers rendered one row above the status row this PR localizes were still hardcoded English, so a Chinese UI showed Minute/Hour/Day/Month/Weekday. Same defect shape as the deleted DAY_OPTIONS array. Chip keys move to stable field ids so a locale that renders two fields with the same word cannot collide, and the truncated header carries a title so longer copy (es 'Dia de la semana') stays readable. * fix(automations): keep weekday option keys stable * test(automations): assert in-place language switch on mounted picker Add a test that changes the language while the weekly picker remains mounted, then checks that the localized labels update while the underlying values ("0"–"6") stay stable. This validates the regression-prevention that stable index keys (added in #15884) support — without them, a locale change would unmount and remount options, breaking the persisted dayOfWeek value. Wrap the picker in a LanguageAwarePicker harness that calls useTranslation(), mirroring the root-level subscription in main.tsx that drives real re-renders on language change. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>succeeded rendered as casual declarative 성공했다 ('it succeeded') next to skipped's polite 건너뛰었습니다 and pending's noun-phrase 보류 중, in a summary that joins all three after a count: '3 성공했다 · 1 건너뛰었습니다'. Machine translation read succeeded as a finished sentence instead of the noun label the other two siblings use. Switch to 성공 and pin it in the key-override file so the catalog regen script can't revert it; #15875 fixed five other keys in the same family but its hardcoded regression map didn't cover this one.* refactor(host): route app paths and version through an AppEnvironment port `app.getPath('userData')` is the single largest Electron coupling in the main process — 37 call sites — and it is one of the things stopping the Orca runtime from booting on plain Node. Give it the same treatment as SecretStore. - `src/shared/app-environment.ts` — the port plus a settable registry, covering the members the runtime's module graph actually reads: paths, app path, version, packaged flag, shutdown hook, exit, and Chromium process metrics. `getAppEnvironment()` throws until installed, for the same reason the secret store does: a silent default resolves `userData` to the wrong directory and the caller writes real state there before anyone notices. No `node:` imports, because `src/shared/**` is in the web build graph. - `src/main/host/electron-app-environment.ts` — the desktop adapter, a pass-through to `electron.app`. - 9 modules migrated: telemetry, opencode/mimo/pi hook services, terminal-history-paths, terminal-scrollback-snapshots, cli-installer, clipboard-image-temp-file, memory/collector. Deliberately NOT migrated: `src/main/browser/**`. That cluster is Chromium- adjacent by nature — cookie jars, download destinations, offscreen pages — and a Node backend does not ship it at all, so porting it buys nothing and churns heavily-mocked suites. Also left alone for now: the call sites that additionally touch `app.asar` path literals or `app.setName`, which need more than a mechanical swap. `getAppMetrics` stays on the port rather than being injected because memory/collector.ts is its only caller and reads it from module scope; a Node host returns [], having no Chromium processes to measure. Test wiring: the secret-store setup file becomes `vitest-host-ports-setup.ts` and installs both ports, exporting `fakeAppEnvironment`/`installFakeAppEnvironment` so suites needing one specific member state only that instead of restating all seven — which is boilerplate, and had pushed one suite past the max-lines budget. Verified: 159 files / 1651 tests pass across every touched area; `tsc` clean on both the node and web projects; `oxlint` clean. * fix(typecheck): list the vitest host-ports setup in the node project Three suites import `installFakeAppEnvironment` from config/scripts, but that directory is outside tsconfig.node.json's include list, so composite typecheck failed with TS6307. Listing the one file matches how this config already pins individual files it needs. Local `tsc --composite false` does not reproduce this — only `pnpm typecheck` does, which is what CI runs. * refactor(host): drop two unused AppEnvironment exports hasAppEnvironment() and resetAppEnvironmentForTests() had zero callers. The secret-store equivalents are used, so these were mirror-symmetry rather than need; add them back when something actually needs them. * test(terminal-history): install the AppEnvironment fake instead of mocking electron These three suites mocked `electron.app.getPath` to point at a fixture dir. The production module now reads the port, so the mock was inert and the global test default's temp dir won — which broke the WSL path assertions and every deletion count. Found by a full-suite run, not by the targeted checks around the migrated modules, which is the argument for running the whole suite on a refactor this wide. * test(host-ports): remove the per-environment temp dir on teardown The setup allocated a mkdtemp directory at module scope, which vitest evaluates once per test *environment* — one per test file, not one per worker. Nothing removed them, so a full 6,000-file run left thousands behind. Proven: with an isolated TMPDIR, a three-file run previously added directories and now leaves zero. * fix(app-environment): anchor the installed environment to a realm global Same reason as the SecretStore: vi.resetModules() rebuilds the module registry, and an environment installed before the reset read back as uninstalled.* build(runtime): gate new Electron imports reachable from the Orca runtime The runtime is meant to become host-agnostic so it can also run on plain Node, but nothing enforced that. `orca-runtime.ts` reaches dozens of modules that import `electron`, and the count grows silently: the import that breaks portability is usually several hops away, so no reviewer sees the edge. Add a reachability ratchet, modelled on the existing max-lines one. It bundles the runtime and its RPC server with esbuild, reads the metafile for every module importing `electron`, and diffs that against a checked-in baseline. A new module fails; a removed one forces the baseline to tighten. The list may only shrink. A per-file lint rule cannot do this — the point is precisely the transitive edges — so this runs as a build gate in `pnpm lint`. Baseline starts at 36, down from 50 before the SecretStore and AppEnvironment ports landed, which is the migration made measurable. Verified: gate passes clean, fails with an actionable message when an `electron` import is added to a runtime module, and passes again when reverted. * fix(runtime-ratchet): resolve paths from the script, not the caller's cwd Run from anywhere but the repo root, the gate died with an unhandled ENOENT stack instead of a usable message. It failed closed, so it was never unsafe — just undebuggable. Anchor ROOT to import.meta.dirname and pass absWorkingDir to esbuild so metafile keys stay repo-relative. * ci(runtime-ratchet): actually run the gate in CI The ratchet was wired into the `lint` npm script, but CI's static-analysis job runs the individual checks rather than `pnpm lint`, so the gate would never have fired on a PR — it would have looked enforced while enforcing nothing. Runs on ubuntu-latest alongside the max-lines ratchet, so the checked-in baseline is only ever produced by one platform. * fix(runtime-ratchet): mark native addons external so CI can run the gate ssh2's optional cpu-features dep points at a prebuilt .node that only exists where a build toolchain has run. Loading it made the gate pass locally and hard-fail on CI with 'Could not resolve ../build/Release/cpufeatures.node'. The gate only reads the import graph, never the addon, so resolve every .node to an external stub instead. Verified by hiding the local prebuild — which is CI's state — and re-running: still 36 entries, exit 0. * fix(runtime-ratchet): stop the gate failing open on Windows The entry guard compared import.meta.url against a `file://${process.argv[1]}` template. On Windows argv[1] is a native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so they never match: main() never ran and `pnpm lint` exited 0 on Windows without bundling, reading the baseline, or enforcing anything. Use pathToFileURL, which is the idiom check-max-lines-ratchet.mjs:225 already uses. CI runs this on ubuntu so enforcement was never actually lost, but a Windows developer got a green gate that checked nothing.* 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* 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. * feat(orcad): boot the Orca runtime on plain Node Closes the last two Electron couplings and makes `orcad` a working artifact: a 4.43 MB Node bundle that boots, pairs, registers a repo, creates a real git worktree and round-trips a PTY — with zero `require("electron")`. Ratchet 2 -> 0, so `config/runtime-electron-baseline.txt` is now empty and its test asserts exactly that: any reachable electron import is a regression. - speech: inject the service factories, so importing ModelManager for its type no longer drags Electron's streaming net.request into the graph - filesystem-watcher: add a WorktreeWatcherRemoval port. Every entry in those maps arrives through an ipcMain handler carrying a renderer sender, so a host with no renderer has nothing to close, restore or forget — the inert default is what the desktop code does against empty maps, not a stub hiding work - user-data-path / profile-storage-paths: resolve userData through AppEnvironment. These surfaced only once orcad pulled the store in Both host ports now anchor to a realm-global symbol. `vi.resetModules()` gives the re-imported graph a fresh module copy, so a binding installed before the reset silently read back as uninstalled. The acceptance smoke drives both hosts through one code path (`--target orcad|electron`) and seeds its own git repo, so it is hermetic and asserts the same contract of each. Wired into PR CI. * test(smoke): remove the seeded workspace container, not just the worktree * test(smoke): surface the server's stderr when it dies before ready * fix(smoke): build node-pty for Node before booting orcad in CI * fix(smoke): drive the CLI built from this checkout, not one on PATH * docs(ratchet): say the baseline must stay empty, not merely shrink * build(orcad): externalize only the native modules actually in the graph* perf(preflight): read the WSL mount table once per shell, not once per CLI The prelude is embedded in the lookup script, and the caller wraps that in `for cmd in <every agent>`, so the unconditional assignment forked awk once per probed CLI -- 36 of them inside the distro against a 10s detection budget. The comment claimed it was read once outside the loop; it was not. `${x+set}` rather than `[ -n ... ]`: a host with no Windows mounts yields the empty string, which must still count as read. Pinned by counting real awk forks through /bin/sh with a stub on PATH, because nothing covered this expression at all -- a wrong-field mutation shipped green. Verified to bind: the unconditional form counts 4 for 4 commands. * fix(wsl): make launch resolve the same binary detection reported Agent detection skips Windows mounts during the PATH walk; the Codex WSL command builder and the WSL branch of isCommandOnPath did not. So Orca could report the guest codex as installed and then launch the Windows one sitting ahead of it on PATH, or disagree with itself between preflight and detection about the same distro. Both now pass the same option. Verified on a real Windows host against a real WSL2 distro, with a Windows binary planted ahead of a guest one on PATH: plain `command -v orcaprobe` -> /mnt/c/Users/neil/orca-agree/orcaprobe this lookup -> /home/neil/.orca-agree/bin/orcaprobe That host reports /mnt/c as 9p, which the mount expression matches, so the fstype list is confirmed against hardware rather than fixtures. * test(preflight): prove the memoised mount list applies past the first command Counting awk forks with a stub that reports no mounts cannot see what the hoist trades correctness for. A mutant that empties `_orca_win_mounts` inside the walk keeps the fork count at 1 and keeps every existing test green, while every agent after the first stops skipping /mnt. This runs two commands behind a stubbed Windows mount and asserts both resolve to the guest binary. Verified against that exact mutant. Credit: review counsel.* fix(workspace-cleanup): name the local context instead of totalling it The delete confirmation showed 'Context: 2', a sum of five unrelated things (terminal tabs, clean editor tabs, browser tabs, diff notes, finished agents) rendered as plain text with no icon or tooltip. A reader cannot tell what the number counts, which is the one thing that screen exists to tell them. Reuse the breakdown the expanded row already renders ('Terminal tabs: 1, Browser tabs: 1') so the confirmation names what deleting would discard. No new strings: the per-kind labels already exist and were already translated. * fix(workspace-cleanup): keep context labels legible`cleanupLegacySystemManagedHooks` reads `~/.codex/hooks.json` and, when it finds no hooks, removes Orca's managed trust entries from the system config.toml and deletes that home's grant-ledger record. Since the STA-4823 read classification landed, `readHooksJsonWithRaw` reports a genuine absence as `{ raw: null, config: {} }` and a failed read as `{ raw: null, config: null }`. Both still fell into the same branch, so a read that merely failed discarded hook approvals the user had already given — and the ledger record that would have let a later pass notice. Only the definitive-absence answer may reach the removal now. Found while reviewing #15417 and not covered by it: that PR fixed the classifier and this is a consumer of it that still collapsed the two answers. The sibling `assertHooksJsonGeneration` guard in codex-real-home-hook-install.ts has the same `existsSync ? read : null` shape and is deliberately NOT changed here. Measured, a permission denial leaves existsSync true and throws from the read, so that path already fails closed; a guard there could not be made to fail in a test and would be unprovable code.* feat(agent-status): add an order-independent title evidence parser The chain this will replace is a first-match-wins scan of substring predicates, so its answer is decided by list position rather than by how strong the evidence is. Four real recorded Grok panes read as Codex today because Codex is checked first and their task text mentions it. Reordering cannot fix that: whichever branch is hoisted, some other pair breaks. collectAgentTitleEvidence collects every signal first and ranks by class afterwards: vendor marker — a sigil or control sequence the agent itself emits; task text cannot forge it anchored name — a name where a grammar reserves the position for identity (Orca's `- <agent>` owner suffix, or the whole undecorated remainder) free-text name — a name anywhere else Anchored beats vendor marker, so `✳ agy` is Antigravity rather than Claude. Free text never beats either, and never becomes identity on its own even as the only name present — an absent icon is recoverable, a confidently wrong one is not. Conflicts within a class resolve to null. No consumer imports this yet. Swapping getAgentLabel in place would move ~20 call sites at once; each migrates behind the resolver, with its own evidence. Measured against the live recorded-title corpus (747 distinct titles), 598 agree with the current chain and 149 differ: 144 claude -> null spinner-only titles. Braille and quarter-circle frames are emitted by many agents, so they prove the pane is busy and nothing about who it is. These are answered by stronger signals once the resolver lands, which is why the parser is not wired up on its own. 4 codex -> grok the misattributed Grok panes, fixed. 1 codex -> null a spinner-prefixed Claude pane whose task text names Codex. Three parser defects were found and fixed by reviewing that delta rather than reasoning about it: the OpenCode envelope was treated as a vetoable marker instead of an owning grammar, free text was allowed to veto a vendor marker (which blinded 13 real Claude titles), and the owner suffix matched the tail of a hyphenated worktree name (`review-14600-codex`). * fix(agent-status): harden title evidence collection * fix(agent-status): limit emitted display evidence * fix(agent-status): anchor explicit title evidence * test(agent-status): cover anchored token filtering * fix(agent-status): complete explicit marker evidence * fix(agent-status): recognize reserved owner ids * fix(agent-status): reject cwd path titles * fix(agent-status): avoid bare-name identity guesses * fix(agent-status): require spinner for working labels * fix(agent-status): honor opted-out synthetic profiles * fix(agent-status): harden wrapper evidence boundaries* fix(sidebar): stop reporting an interrupted agent as done (STA-5357) An interrupted turn rendered on the worktree card and the tab glyph as `done` — visually identical to a clean completion. A cancelled or dead turn read as finished work, and with several agents it contributed no signal at all, so a sibling that merely finished could hide it entirely. `interrupted` is a flag clamped onto `done` at parse time, never its own state, so the activity summary never even received it: its Pick was {state, workingMode}, and the `done` branch swallowed it into hasLiveDone. Adds the flag end to end — summary, both activity hooks, the resolver, the card glyph and the tab badge — and slots it below permission and above working, which is where SUMMARY_STATE_ORDER already ranked it for the text summary. The card and the expanded agent list now agree. The tab-bar test that asserted the old behavior is flipped rather than deleted: it explicitly pinned `done` to mirror the card, and that premise is gone. * fix(sidebar): rank interrupted below every live state Corrects the precedence from the previous commit. Interrupted means the user pressed Esc or Ctrl+C — a deliberate act, so they already know. It must be DISTINGUISHABLE from done (that is the bug) without being LOUDER than anything live. Moved below done in all three ladders — resolveWorktreeStatus, the tab attention badge, and SUMMARY_STATE_ORDER, which had also ranked it above working. That matches smart-attention, which already classes an interrupted done as 4 (idle), below both done and working; the card, the tab, the summary text and the sort now agree instead of two of them disagreeing. Tests re-pinned to the corrected order, including one of my own from the previous commit that asserted a finished sibling must not mask an interrupted pane — it should, and now does. * fix(status): preserve interrupted outcomes in aggregates* fix(terminal): stop OMP tab title flapping between OMP and Pi OMP wraps Pi, and both share the `pi-compatible` title-identity group. Two writers publish frames for the same pane under different labels: main's synthetic spinner injects "<frame> OMP" every 80ms, while the wrapped Pi harness emits its own "Pi" frames. `isDecorativeAgentTitleFrameChange` keys on `status:textWithoutSpinner`, so `working:OMP` and `working:Pi` read as meaningful changes. The alternation defeated spinner-churn suppression entirely: every 80ms frame committed a store patch plus a runtime-graph sync, on both the tab-title and runtime-pane-title paths. Pin same-group identity frames to the tab's launch owner at both store choke points, reusing the existing owner-normalization helper already applied on the sidebar, remote-sync, and mounted-pane paths. The relabel is scoped to bare identity frames ("⠋ Pi", "Pi ready"); a semantic session title ("π - <session> - <cwd>") carries text no agent profile can reproduce and is left untouched, so this does not reintroduce the generic-label complaint in #16093. * fix(terminal): scope owner relabel to cross-identity frames A frame that already names the tab's own agent carries authoritative status wording, so relabeling it restated bare "Pi" as "Pi ready" and changed a Pi-owned tab that never flapped. Only relabel when the frame names a different member of the identity group. Also fixes the repro suite's types against the project typecheck. Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com> --------- Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>* refactor(workspaces): split lifecycle modules * preserve workspace cleanup consent contract * restore workspace delete shortcut hint in context menu view * restore host-qualified visit recency and viewed-candidate predicate * test(cleanup): pin the viewed-mark upgrade path and host-qualified visit reads Two invariants a refactor broke in this PR, both silent: - viewed marks are persisted, so gating `shouldPreserveCleanupInspection` on any newer field voids the grace period for every entry written by an older build - visits are stamped under `${hostId}|${worktreeId}` whenever the host is known (the normal case, including 'local'), so a bare map[worktreeId] read misses every modern entry and yields 0, disabling the recent-visible-context blocker Verified discriminating: reintroducing each bug fails exactly its own test.The pairing ratchet matched spawn|spawnProcess|spawnSync|runProcess only, so a resolved CLI handed to execFile was the same unpaired launch with none of the enforcement. codex-trust-grant-host.ts resolves codex and calls execFileSync, and escaped the ratchet purely through that omission. Widen to the exec/fork family. The negative lookbehind keeps method calls such as `RE.exec(` out, which is what made the bare `exec` name safe to include; a fixture mutation confirms `/x/.exec('x')` does not trip it, and adding execFileSync(resolvedCli) to a paired file does. codex-trust-grant-host is allowlisted rather than changed: its only exec is a wsl.exe identity probe for the binary stamp, and its actual codex launch is a CodexAppServerInvocation paired centrally in codex-app-server-session. The entry records what would invalidate it.* fix(crash-reporting): correlate concurrent process deaths on a renderer report Two 1.4.184 reports (a326935a, 1862f316) are renderer "crashed"/-1 crash reports whose renderer only died alongside a sibling Chromium child that died at the same instant: F0BQMB30GJX network.mojom.NetworkService crashed/-1 -21ms -> renderer crashed/-1 F0BRPP8TC0Y audio.mojom.AudioService crashed/-1 -2ms -> renderer crashed/-1 GPU crashed/-1 +180ms process-gone-classification.ts classifies each event in isolation: isRecoverableChromiumChildProcess discards the utility/GPU halves as recoverable churn, and `if (reason !== 'killed') return true` then reports the renderer half as a genuine renderer crash before any cross-source signal exists. Triage reads "renderer crashed" for what died with three other processes. process-gone-sibling-correlation keeps a bounded ring of child deaths, populated before the suppression early-return so churn-suppressed siblings stay visible, and matches a renderer death against child deaths sharing its failure signature. What the timing can and cannot support: - The window is asymmetric. 1s of lookback (a child that died first can plausibly have taken the renderer with it), but only 250ms of lookahead: a child dying well after the renderer is at least as likely to be an effect of it — Chromium tearing down the dead renderer's channels, or renderer_recovery_reload at +264ms — and a symmetric window would retro-label a genuine lone crash as collateral. - crashAttribution is 'concurrent-process-deaths', not a causal claim. The largest 1.4.184 cluster is an external taskkill /T where renderer and children are co-victims; no sibling caused anything there. - The verdict is not derived from timing alone. A host with a child looping at the observed 1459/min drops a death into every window, so crashAttribution is set only when the nearest sibling is within 250ms and no identity repeats. Looser or repeating deaths still ship as evidence (siblingProcessDeathCount, signed offsets, siblingProcessDeathRepeats) with no attribution. - The signature match buckets `crashed` with `abnormal-exit` and gates on the exit code only on win32. Both fixtures are win32, where every process in a collateral pair reports crashed/-1; POSIX surfaces a per-process wait status, so an equality gate would mean this never fires on macOS or Linux. The report stays reportable and gains evidence rather than being suppressed (#14667). Both arrival orders are covered without delaying persistence: a sibling that dies first is folded into the initial record, a sibling that dies after amends the record already on disk through attachDetails, the same way the minidump signature does. Late amends are capped at two per report and skipped when the rendered evidence is unchanged, so a crash-looping child cannot rewrite the store during renderer recovery, and a failed amend now leaves a sibling_attribution_attach_failed breadcrumb instead of vanishing. Relationship to #12484: it is still OPEN and adds process-tree-kill-window.ts, the same ring/lookback bookkeeping with a 250ms settle, patching the same recorder hunks with the opposite policy (suppress the killed/1 renderer report instead of keeping it). #14667 is test-only — it pinned the keep-the-report policy in tests, it did not remove a shipped implementation. #12484 has to be closed or rebased out before this lands. * chore: remove merge hook formatting drift* refactor(ipc): split repos.ts into focused modules * test: point repo notification mocks at the extracted module * fix(ipc): repoint the child-process allowlists after the repos split The type-only `import type { ChildProcess }` moved from repos.ts to repos/repo-clone-lifecycle.ts, so the import-boundary entry follows it and the windows-console entry (now stale, and that list only shrinks) is dropped. Fixture-only; the base file had no runtime child_process use at all.`createWebRuntimeSessionTerminalResult` collapsed an explicit `environmentId: null` ("I resolved ownership and nobody remote owns this") into "caller said nothing", then fell back to `settings.activeRuntimeEnvironmentId`. The tab-strip "+" shell rows and the guest-focus Ctrl+T relay both pass that explicit null, so a local workspace's new terminal was created against whatever remote runtime happened to be focused, which answered `selector_not_found` for a worktree id it had never seen. The same call selects the runtime as the workspace's execution host before the create, and the error path never handed that selection back — leaving the workspace latched to the runtime that just refused it, so every later owner-routed action (the next Ctrl+T included) silently followed the latch until a workspace switch reset it. Fixes #16444* Add all-host automations with scoped ownership and multi-authority suppo Enable automations to run on multiple hosts (SSH targets and local) with owner-fenced mutations, scoped list queries per host, and conflict resolution. Introduces desktop and runtime authorities as distinct automation storage owners, with per-host caching, invalidation, and retry scheduling on the renderer. Captures registration generations for SSH hosts to survive re-adoption. Adds CLI support for destination selection and conflict recovery. * Filter automation create projects by destination host Only offer projects available on the selected destination, preventing the mismatches that would fail at submit time. Auto-adjust the project selection if it becomes unavailable when the destination changes. * Add runtime storage authority support for automations - Support both runtime and desktop as automation storage authorities - Make owner preconditions optional for legacy-client compatibility - Cache automation list projections to improve performance - Add per-row repo/worktree resolution for cross-authority collisions - Extend automation.list RPC to always include owner metadata * Replace child_process.execFile with runProcess for external automations - Migrate external-manager to use cross-platform runProcess wrapper per child-process safety policy - Abstract electron app/ipcMain APIs in orca-runtime via environment accessors - Install fake app environment in automation tests for consistent setup - Reorganize imports to use specific module paths (ssh-target-registry, agent-detection, browser-error) - Remove external-manager from child-process import allowlists (no longer violates direct import) * Unify desktop automation CRUD onto the local runtime RPC surface The desktop authority now speaks the same automation.* RPC contract as remote runtimes, via callRuntimeRpc({kind:'local'}) -> runtime:call -> the shared RpcDispatcher. The automations:list/listRuns/create/update/ delete/runNow IPC arms, their preload members, and every renderer desktop-vs-runtime transport fork are retired; the runtime methods are the single implementation of scoped lists, owner fencing, and change publication for both transports (mobile clients already exercised them). The desktop probe scheduler's priority lease survives the move as an AutomationService hook the IPC registration installs and the runtime methods take, so Orca's own automation traffic still parks queued external-manager probes. External-manager scope arms and dispatch-loop plumbing stay on IPC by design; automation change events keep their existing channels (renderer ingestion already converges them by authority). * Remove automation ghost SSH tombstone scanning This functionality for synthesizing tombstones for automation-referenced SSH targets is no longer needed as part of the automation system refactoring. * Refuse orphan automations at dispatch time, not migration time Remove migration-time disabling of orphan automations and the `enabledDecidedBy` field. Dispatch now refuses orphans at runtime instead, simplifying state management and UI. Orphans are left unstamped and enabled; dispatch refuses to run them via `resolveAutomationRunTarget`. * Show all automations in flat table with unified filter menu - Replace host picker component with comprehensive Filters menu supporting status, last run, agent, and host filters - Flatten automation list layout to single table instead of host-grouped sections - Add Host column to display execution host for each automation - Display active filters as removable pills below toolbar - Delete unused AutomationHostPicker* components * Add automation owner fencing and destination validation - New AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY for owner preconditions; legacy clients get owner metadata snapshotted at RPC boundary for compatibility - Editor captures and revalidates automation destination before save, preventing silent retargeting if SSH infrastructure changes mid-edit - SSH target types now isolate renderer-authored fields; generation is server-owned and stripped by IPC handlers * Route automation recovery actions to the origin host When an automation action fails due to owner fencing, recovery verbs ("Update server", "Reconnect") must run on the host where the refusal originated: the row's captured owner for row operations, or the destination the create dialog captured, not the list's filtered host. * Remove external manager scope limitation notices Consolidate create destination eligibility checks with a unified predicate and fix the bug where desktop repo IDs could be sent to runtime hosts where they cannot resolve. * Persist only store-derived automation contexts, not client-perspective o Store contexts must never be based on client-provided runContext or sourceContext values—clients speak a different perspective (e.g., 'runtime:<id>' for host IDs they assign), and persisting those makes the store projection orphan automations it actually owns. Derived contexts now take precedence in create and update paths, with explicit null still honored to clear a value. Tests verify this by simulating drift after storage and confirming that moves re-derive while toggles preserve.* fix(agent-hooks): stop the hook launcher spelling the AV-denied flag triple Orca's Windows agent-hook launcher ran powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden \ -EncodedCommand <base64> That exact combination is the textbook "hidden encoded PowerShell" malware shape, and endpoint security denies it at process creation whatever the payload decodes to -- even `exit 0`. Every injected hook then failed with `powershell.exe: Permission denied` (exit 126 from bash's execve, EACCES) on every turn, for both Claude Code and Codex, with no AV exclusion that re-enabled it. Dropping any one of the three flags clears the signature. `-ExecutionPolicy Bypass` is the one that can move: it sets the Process scope, and so does `Set-ExecutionPolicy -Scope Process`, which now rides inside the encoded payload. `-EncodedCommand` is never policy-gated, so the bypass always gets to run before the managed script does -- which is what keeps Copilot's .ps1 hook working under a Restricted or AllSigned machine policy. The hidden window and the encoding are unchanged, so nothing regresses for #14815, #14818 or #6078. Closes #16003 * fix(agent-hooks): ship the launcher shape #16003 actually measured as allowed The previous revision of this branch dropped only `-ExecutionPolicy Bypass` and kept `-WindowStyle Hidden -EncodedCommand`, on the reasoning that "dropping any one of the three flags clears the signature". That sentence is not in the bisect. The reporter ran exactly four command lines on the affected Kaspersky/Windows 11 host: -NoProfile -WindowStyle Hidden -Command 'exit 0' -> 0 -NoProfile -EncodedCommand <b64> -> 0 -NoProfile -ExecutionPolicy Bypass -Command 'exit 0' -> 0 -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand -> 126 Every passing row drops two flags. No row drops exactly one, so the shape the branch was about to ship had never been executed on the machine that reports the bug -- and it is `-WindowStyle Hidden -EncodedCommand`, which is the "hidden encoded PowerShell" pair the denial is named for in our own comment. Shipping it would have closed #16003 while leaving every hook on that host dying at CreateProcess, with no tracking left open. So emit the measured-passing encoded row instead: `-NoProfile -EncodedCommand`. Of the two flags there was a choice between, `-EncodedCommand` is the one that carries correctness -- it is what keeps paths and switches intact across cmd.exe and MSYS (#6078, #14815). `-WindowStyle Hidden` costs at most a console flash, and only where the parent has no console to inherit. Second, the relocated bypass now runs inside try/catch. Under a MachinePolicy or UserPolicy GPO scope, `Set-ExecutionPolicy -Scope Process` reports that the process scope did not take. `-ErrorAction SilentlyContinue` covers only the non-terminating half of that; the command-line switch it replaces was silent either way. This file already documents that non-stdout PowerShell streams corrupt consumers merging our output into JSON stdout, so a per-invocation ErrorRecord on stderr is a regression we should not trade for the switch. Refs #16003 * fix(agent-hooks): keep the hook console hidden while dropping the AV-denied flag Round 2 of this PR widened the fix from "stop spelling -ExecutionPolicy Bypass" to "stop spelling it and -WindowStyle Hidden", on the reasoning that the #16003 reporter never measured a shape that drops exactly one flag, so keeping the hidden+encoded pair would be extrapolation. That trades a reproduced regression for an unmeasured one. Window suppression is the shipped fix for #14815 and its four duplicates (#14828, #15117, #15447, #15767): a hook launched from a parent with no console gets a fresh console per event, which takes foreground and eats whatever the user is typing into Orca, and never closes at all on the stdin-blocking path hook-stdin-contract.ts exists to guard. That fires on every prompt, tool call and stop of every managed agent. The AV denial, by contrast, is measured only for the full triple; that the remaining pair still trips it is a hypothesis. Between a certain regression and a possible one, keep the certainty. So the flag that leaves the command line is the policy bypass alone — the only one of the three with an exact in-payload equivalent, hence the only one that can move without losing behaviour. If the pair turns out to be denied too, the answer is a different shape that still hides the window. * fix(agent-hooks): silence progress before the policy bypass can autoload (#16621) Hardware-measured on Windows 11 while exercising #16576. Set-ExecutionPolicy autoloads Microsoft.PowerShell.Security, and that module's "Preparing modules for first use." progress record is written before any later assignment can suppress it. Running the bypass first therefore defeated the silencer that runs immediately after it: bypass-first stderr = 616 bytes, first merged line '#< CLIXML' silencer-first stderr = 0 bytes, first merged line '{"decision":"approve"}' That is precisely the corruption HOOK_PROGRESS_SILENCER's own comment warns about -- redirected progress becoming CLIXML that can corrupt merged JSON -- so the PR reintroduced the hazard it documents, one line below documenting it. Both existing tests asserted the broken order, so they enforced the bug rather than catching it. Reordered them and added one that pins the ordering itself rather than the literal string, since the string will drift again.Reply echo suppression modelled two echo shapes from the spec rather than from a tty. Captured under node-pty against real bash, at a readline prompt and under `read`: - Readline mangles CSI replies, not just OSC: `ESC [ ?` becomes BEL and the residue echoes. The projection was gated on an OSC introducer, so a private DSR echo was never matched at a readline prompt. This is the reachable one: a mode-2031 theme push (`CSI ?997;1n`) left latched by an exited TUI paints `997;1n` on a bash prompt (#9993's scenario). - ECHOCTL carets EVERY control, not just ESC. A BEL-terminated OSC reply echoes as `^G`, but the needle kept a literal BEL — a string no tty produces. Hardening only: every in-tree OSC reply is ST-terminated (terminal-osc-color-reply.ts:112, xterm's own reply), so the changed byte is unreachable except from a foreign or older emulator. Why this is not the CSI projection #13160 review dropped: that one was the identity (`replaceAll('\x1b]', …)` is a no-op on a CSI reply), so it was ESC-led and 500ms-held bare-ESC tails away from the query parser. This one is BEL-led. The rule is now asserted for every shape rather than implied by the gate: holdPartial iff the needle does not start with ESC. The readline branch is keyed on the private-DSR grammar with a non-empty parameter list, plus a floor on needle length. The containment grammar admits `CSI ? n`, and `answerLiveQueryReply` takes client-supplied bytes on the relay path, so a peer could otherwise arm a two-byte `BEL n` needle and delete the first bell-then-`n` in ordinary output. #61c65151129 proved this system can eat real output when a needle outlives its budget; a length floor is cheap. Live coverage: pty-reply-echo-shapes.node-pty.test.ts writes a reply to a real bash master and feeds back what it echoes, so a shell or libc change fails the suite instead of silently disarming suppression. Registered in the shell-contracts lane. The transcript tests and the caretEcho helpers that encoded the same ESC-only assumption are corrected alongside. Suppression is display-only. This does not change what reaches the child's stdin — the reply is written to the master either way, in call order.* fix(mobile): quantize chat pinch font scale so a zoom stops re-measuring the list every frame A user bubble on a 390pt iPhone painted five lines inside a frame that reserved six, with the last painted line cut through a glyph at the content edge and "no longer needed." gone. The paint is React Native's: a `<Text>` with no `numberOfLines` gets a text container whose `lineBreakMode` is `NSLineBreakByClipping` (RCTTextLayoutManager.mm). Measure lays out into `{width, CGFLOAT_MAX}`, paint lays out into the mounted content frame — so a frame one line short does not re-wrap, it dumps the remainder onto the last fitting line and clips it, with no ellipsis. Reproduced on-device against the real component with the message text held constant, so five painted lines can only be truncation. Two conditions are each necessary, and removing either makes it vanish over ~6000 measured bubble renders: a pooled `RCTParagraphComponentView` carrying a shorter row's content frame (`prepareForRecycle` clears `state` but not `_textView.layoutMetrics`), and whole-list re-measure churn while rows enter and leave that pool. The churn was ours. `renderItem` closes over `fontScale`, and the pinch handler committed a new scale on every gesture frame, so one zoom drove hundreds of full-list re-measures. The pinch is composed `Simultaneous` with the list's own scroll, so a stray second finger during a scroll started that storm at scales the user cannot see — matching the report, whose glyph metrics are `fontScale` 1.0 exactly. `quantizeFontScale` snaps commits to a 5% grid. React bails out of a same-value `setState`, so gesture noise now commits nothing and a full-range pinch commits at most ~20 times. Under the churn that produced 67 defects in 6636 bubble renders, the quantized build measured 0 in 6064 — with a forced-defect control bubble flagged in 100% of frames of both runs to prove the detector was live. This removes the trigger we own; it does not close the RN recycling window itself. That needs a one-line reset in `prepareForRecycle`, which cannot land here without refreshing the `patchedDependencies` hash under `mobile/`. * fix(mobile): reset recycled paragraph layout before reuse* wip: normalize untrusted project catalog rows at load boundaries * fix(catalog): make ProjectHostSetup field types true at the ingest boundary Crash 3bcc5be3: a setup row whose repoId arrived null reached Settings' projectByRepoId memo and threw on .trim(). The type said `string`; persisted JSON and remote hosts on other versions can disagree. Normalize project/setup rows where untrusted data enters typed code — the persisted-state load (marking dirty, which is the migration), profile transfer reads, the repo-derived projection, and the renderer's IPC/RPC ingest and adoption steps — instead of re-guarding each consumer. Also covers `setup.path`, whose identical crash is on the sidebar render path. Coercion only: never drops a row, never adds or removes an optional key, and returns input references when a row already conforms, so selector and useMemo identity is unchanged. * refactor(catalog): drop the `as` casts the normalizer introduced A change whose thesis is "stop the declared types from lying" should not use `as` to paper over types. The four source casts all came from the row normalizers returning `readonly` arrays into mutably-owned fields. Take and return mutable arrays instead, and copy at the one caller that holds a readonly projection — where identity is not load-bearing, unlike the persistence path, whose dirty check compares it. Tests built deliberately malformed rows by casting a literal. Build a valid row and `Reflect.set` the bad value onto it, which says outright that the fixture violates its type; parse the non-array case from JSON, which is how it actually arrives. Fixtures that were merely incomplete needed no cast at all — `Repo` requires only the five fields they already had. Also narrow normalizeLoadedProjectCatalog to the two fields it reads. * refactor(profiles): validate untrusted profile JSON instead of asserting it Removes the last introduced cast and the unsound pre-existing ones in the file this change already touches. The test cast is gone because narrowing normalizeLoadedProjectCatalog to the two fields it reads made `{}` assignable on its own. arrayOrEmpty and recordOrEmpty checked the shape and then asserted the element type, which is the same "declared type is a promise the data does not keep" problem this change exists to fix. Array.isArray already narrows on its own, and a generic isRecord narrows the value part, so both assertions delete outright. JSON.parse returns any, so annotating the binding beats asserting its result. The two remaining `as const` in project-host-setup-actions are untouched and deliberate: a literal assertion narrows a type rather than overriding it. * Make project catalog normalizers handle null rows defensively Instead of crashing when corrupt or null catalog rows are encountered, the normalizers now gracefully repair them with default values. Refactored helper functions for clarity and added type predicates to improve type narrowing.* feat(agent-status): add the pane agent identity resolver Four ladders answer "which agent is in this pane" independently — the tab icon, the open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback — and they disagree. Two consult the terminal title before the launch record, so a string Orca parsed outranks a fact Orca owns. resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering: 1. Evidence is ranked by how directly it observes the process; a display title is last. 2. Each observation carries the runId of the agent run it describes. Evidence from a superseded run is INELIGIBLE, not merely outranked. Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) — identical signals, opposite correct answers. Run ids make them different facts: in the bug both belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as a test asserting the two produce opposite answers from the same evidence. Missing run ids are treated as eligible. Absence means "this peer does not publish them", not "this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so pane-scoped consumers cannot inherit another pane's agent. No consumer imports this yet; each migrates separately with its own evidence. Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the run filter fails 3. * fix(agent-status): close three resolver contract holes found in review **Duplicate evidence of one source resolved by array order.** `eligible.find(...)` returned the first match, so two live hooks naming different agents were settled by input position — the exact property this resolver exists to remove. The original order-independence test only used DISTINCT sources, so it never exercised it. Conflicting same-class evidence now returns null with `ambiguousAt`, and does NOT fall through to a weaker source: letting a title answer whenever two hooks disagree is worse than saying nothing. **A bare numeric runId collided across authority restarts.** `incarnation` is a total order only within one `authorityId` (agent-status-observation.ts states this), and the id is regenerated per authority instance, so a restarted host counting from its own floor would report `1` and match an unrelated live run 1. The run key now carries its authority, and evidence from a DIFFERENT authority is treated as incomparable — kept, like an absent key — rather than as stale. **Title stayed reachable by consumers that authorize writes.** Ranking it last makes misuse unlikely; `minimumSource` makes it impossible. An action consumer passes `'launch'` and weaker evidence is dropped before ranking, so routing or delivery cannot name a target from a parsed string even by reordering its inputs. Display surfaces omit it and are unaffected. Also restores the generic agent-vocabulary parameter, which lives on the routing branch and was lost when this branch was rebased. Each fix is mutation-verified: first-match restored fails 3, ignoring authority fails 1, dropping the floor fails 2. The authority test was itself vacuous on the first attempt — both sides used `incarnation: 1`, so a resolver ignoring authority still passed on the numeric compare. It now uses differing incarnations. The remaining review finding, that `process > launch` has no freshness bound, is NOT fixed here: it needs an observation timestamp the evidence type does not yet carry. Recorded rather than silently dropped. * fix(orchestration): route @agent messages by resolved identity, not terminal title `@claude` picked its recipients with `buildAgentNameRe('claude').test(title)`, so any pane whose TITLE contained the word received Claude's messages. Terminal titles carry task text, and people describe agent work in them, so this is the ordinary case rather than a contrived one: the recorded title "Switch Claude and Codex off the load balancer… - grok" is a Grok pane that received both @claude and @codex. Misdelivered instructions, not a cosmetic slip. The cause is that `RuntimeTerminalSummary` carried no identity at all — `title` was the only identity-ish field on it, so routing by title was the only option available. Fix the input: - `RuntimeTerminalSummary.agentIdentity?: TuiAgent` — optional, host-resolved from launch and foreground-process evidence the host owns, with the title ranked last and contributing only when the evidence parser finds an unambiguous name. A title that merely mentions an agent yields no evidence, which is the whole point. - `resolvePublishedPaneAgentIdentity` in `src/shared` rather than inside the runtime class, so the decision is testable without a runtime and so routing, delivery and the UI cannot drift. - Groups match `agentIdentity`; the title matcher and its bespoke Cursor predicate are deleted. Unknown fails closed. `agentIdentity` is absent when the host predates the field or had no evidence beyond the title, and delivery is an action: not delivering is visible and recoverable (the sender sees no recipients), while delivering to the wrong agent is neither. The optional field is additive, so an old client simply ignores it (wire rule 1). This is also the first real caller of the evidence parser and the identity resolver. Tests: 27 in groups, 8 for the publisher, 3 RPC fan-out cases updated to the new contract. The `@cursor`-must-not-match-"text cursor blink" hazard is now excluded structurally instead of by a per-agent predicate. Verified non-vacuous by mutation: swapping the process/title ranks fails 2 publisher assertions, and reverting groups to title matching fails 15 of 27. One earlier mutation silently failed to apply after formatting reflowed the block — the file was checked before trusting the result. * perf(runtime): reuse terminal title during summary build * fix(orchestration): refuse title evidence when publishing identity for routing Rebuilt on current main so this carries the hardened parser from #16148 and the corrected resolver from #16157 (authority-scoped run keys, no order-dependent duplicate resolution). Applies the resolver's new `minimumSource` floor at the publisher. What this publishes authorizes an action — routing decides which real agent pane receives a message — so ranking title last is not enough; the floor removes it from consideration entirely, and no amount of reordering by a caller can bring it back. The trade, stated because it is a real capability loss: a hook-less agent over SSH that Orca did not launch, and whose foreground process the host cannot read, is no longer addressable by @agent. Accepted because a message delivered into the wrong agent's prompt is unrecoverable while an undelivered one is visible — the sender sees zero recipients. Whether real panes actually carry launch/foreground evidence is the open question, and is what live validation must answer. * fix(pty): preserve agent identity on daemon reattach * fix(runtime): retire stale pane agent identity * chore: normalize runtime types formatting * fix(agent-status): identify a pane from its own hook, not from how it was started Two defects, one cause: identity was inferred from the outside instead of read from the agent. **Hook evidence was never plumbed in.** The publisher considered `process`, `launch` and `title` and contained zero hook references — while the resolver ranks `live-hook` first. The top rung of the ladder was never connected. That made identity depend on Orca having launched the agent. Most agents are started by typing `claude` or `codex` at a shell, which leaves no launch record. On macOS the foreground process still names them, so the gap was invisible. On WSL the Windows host reads the foreground process as `wsl.exe` — the distro wrapper, not the agent inside it — so those panes had no signal at all and became unaddressable by `@agent`. A hook is the agent reporting itself, so it survives both: no launch record needed, and no dependency on reading a process across the WSL boundary. **`launch` outranked `completed-hook`.** Ranking is now by TENSE rather than by how authoritative a source sounds: present: live-hook > process past: completed-hook > launch > sleeping-session > sibling > title A launch record is an event, not a state — it stays true after the agent exits, which is why a pane reused after closing its agent kept reading as the old one. A completed hook at least proves the agent actually ran in that pane; a launch record only proves Orca tried to start one. Neither rank was covered: all 392 existing tests passed unchanged after reordering. Mutation now fails 2 on the old order and 4 with hook evidence removed. Known remaining gap, deliberately not papered over: a hand-started WSL agent with no managed hooks has no identity signal at all. Restoring a title guess there would reinstate the misdelivery this PR exists to prevent. * fix(orchestration): restore title as the last resort, not a forbidden source An earlier revision passed `minimumSource: 'launch'` so routing could not see a title at any rank, reasoning that a display string must never authorize a write. That conflated the evidence parser with the raw substring match it replaced. `buildAgentNameRe('claude').test(title)` was the misdelivery. `collectAgentTitleEvidence` returns null on exactly those shapes: "Review the Claude session-history fix" on a Codex pane yields nothing, and "Switch Claude and Codex off the load balancer… - grok" yields grok from its owner suffix. Ranking title last is therefore sufficient; refusing it is not necessary. Refusing it had a real cost. An agent a user starts by hand inside an Orca WSL terminal has no launch record, no readable foreground process (the Windows host sees `wsl.exe`, not the agent in the distro), and — until managed Codex hooks install there — no hook either. An unambiguous title was the only thing left, and dropping it made that pane unaddressable by @agent where the previous code could reach it. That is a regression, and most agents are started that way. End-to-end coverage added at the routing layer with title allowed: @claude still does not reach a Codex pane whose task text names Claude, @codex still does not reach a Grok pane whose task text names Codex, and a pane identified only by an unambiguous title is reachable again. * revert(agent-status): keep launch above completed-hook until run keys exist Reverts the tense-based reorder from this branch. The reasoning behind it was sound as far as it went — a launch record is a past event, not an observation, which is why a reused pane kept reading as its previous agent — but it fixed one staleness by opening a worse one. A completed hook is past tense too, and without an agent-run key it never expires at all. Ranking it above `launch` lets a stale hook from a previous agent outrank the launch record Orca stamped for the process running NOW. pane-agent-owner.ts already says this in its own comment: "Ranking launch/live-hook above the completed/sleeping records keeps a genuine pane on its real agent and stops a stale record from hijacking it." The reorder belongs with authority-scoped run generation, which is what makes any past-tense evidence expire. It is staged in the migration plan rather than shipped here. What this branch keeps: hook evidence feeding pane identity (so an agent a user starts by hand is identified from its own report rather than needing a launch record), and title restored as a genuine last resort behind the evidence parser. * fix(runtime): guard the pane key so terminal.list survives a non-UUID leaf `makePaneKey` throws on a leaf id that is not a UUID. The hook-evidence lookup called it unguarded inside `buildTerminalSummary`, so a single such leaf took down `terminal.list` for the whole list rather than degrading that one pane — 136 tests across 5 files, and the native code-quality gate tripped separately on a duplicate test title. Both were mine, and both were caught by CI rather than by me: I ran the focused suites before pushing instead of the affected directories. * fix(runtime): declare published terminal agent identity * fix(runtime): demote completed hook identity evidenceThe Docker-SSH e2e lane only ran when a PR's changed specs happened to include `ssh-startup-exec-readiness.spec.ts` or `paired-startup-exec-readiness.spec.ts`. Editing SSH source itself did not trigger it, and pruning either spec from a route's list would have silently retired the whole lane. Meanwhile the sharded lanes set no `ORCA_E2E_SSH_DOCKER`, so every Docker-gated spec skipped itself while the shard still reported green -- the exact silent-skip shape `docs/reference/ssh-reconnect-source-recovery.md` blames for four regressions that reached users. Separately, the modules that actually own direct-SSH workspace and tab restore carry no "ssh" in their names, so the `ssh-terminal-source` route never reached them. Measured on the real script before this change: printf '%s\n' src/renderer/src/hooks/remote-workspace-session-merge.ts \ src/main/ipc/remote-workspace-snapshot-normalization.ts \ src/renderer/src/lib/worktree-initial-terminal-seeding.ts \ src/shared/remote-workspace-session-projection.ts \ | node config/scripts/pr-e2e-source-routing.mjs => [] Three changes, all pinned by the executable gate contract: - `hasSshSourceChange` derives an `ssh_source_changed` signal from the SSH routes themselves, plumbed pr.yml -> e2e.yml, so the lane triggers on source rather than on a spec name surviving in a list. One list, so the two cannot drift. - A sibling `ssh-workspace-session-restore` route names the restore seams (`remote-workspace-*`, `worktree-initial-terminal-seeding`, `worktree-default-terminal-tabs`, `initial-terminal`) and routes them to the two restore specs -- a sibling rather than more paths on `ssh-terminal-source` so a tab-tombstone edit does not run the whole SSH terminal list. - A new `test:e2e:ssh-docker` runner claims the remaining Docker-gated specs on the one VM that sets the flag, and the contract now fails by name when any Docker-gated spec is claimed by no runner. `ssh-docker-relay-perf` and `ssh-codex-display-artifacts-repro` are recorded exemptions (wall-clock budgets; needs a real remote codex binary) and the contract asserts each exemption still corresponds to a real gated spec, so a stale one cannot quietly excuse a gap. Lane timeout raised 35 -> 60 minutes for the added serial specs. The lane's first act was to surface four latent bugs in a spec that had been silently skipping. `ssh-docker-bulk-open-freeze-repro.spec.ts` is four call sites out of date against `tests/e2e/helpers/terminal.ts`: `startDockerSshRelayTarget()` is called with no argument though the helper dereferences `testInfo.workerIndex` (a 100% failure, not a flake), `execInTerminal` gained a `ptyId` parameter, and `splitActiveTerminalPane` gained a direction. It was invisible because it ran nowhere and `typecheck:e2e` is red on main with 240 pre-existing errors, so four more could not be seen. The `testInfo` bug is fixed here -- correct on its own, and it removes one real error from `typecheck:e2e` (240 -> 239). The other three are not, because they are not argument plumbing: repairing them requires choosing which ptyId to capture and which split direction to use, and both change what the repro measures. The spec is therefore added to the exemption list rather than repaired, for two independent reasons recorded in the runner: it is a perf oracle, not a correctness one (`SOFT_FREEZE_LAG_MS=2500` / `HARD_FREEZE_LAG_MS=5000` measured under a deliberate 5-pane flood on a 420s budget -- the same rule already applied to `ssh-docker-relay-perf.spec.ts`), and it is known-rotted. Repair is tracked in stablyai/orca#16764. Applying an existing written rule to a sibling that plainly meets it is consistency; inventing a new exemption to dodge a red would not be. Three hardening fixes to the contract itself: - Runner text is comment-stripped before the claimed-by-a-lane scan. A substring scan over raw text lets a spec merely *discussed* in a runner comment count as claimed -- the silent skip this assertion exists to catch, re-entering through the documentation. Not live today only because the existing comments write the spec names without their `tests/e2e/` prefix. - An exempt spec must not be invoked by any runner. `unreachableSpecs` short-circuits the unclaimed check, so a spec could be documented as exempt while a runner still ran it -- an exemption that reads as coverage removal but changes nothing, leaving the lane red for a reason the file says it excluded. This is not hypothetical: adding the bulk-open exemption without removing it from the runner's spec list produced exactly that state, and this assertion is what caught it. - The Docker-gate detector is now `/ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/` rather than one fixed string, so a double-quoted or `!==` spelling can no longer escape the contract. `ssh-restart-tab-accumulation.spec.ts` is a new three-cycle restart fence asserting tab-id set identity, not just the active pane's reclaimed ptyId as `ssh-cold-activation-restore.spec.ts:241` did. It passes today; it was validated by a negative control that injected one tab after cycle 1 and correctly failed.`defaultTerminalTabsAppliedByWorktreePath` is declared on the wire session (`src/shared/remote-workspace-types.ts:16`), emitted by the renderer's export (`remote-workspace-session-projection.ts:101-134`) and read back by its importer (`:195`) -- but `normalizeRemoteSession` rebuilds the session as an object literal and never copied it. Measured on a snapshot carrying `{ '/r': true }`: normalizeSnapshot(...).session.defaultTerminalTabsAppliedByWorktreePath => undefined remoteWorkspaceSessionMatchesSnapshot(withFlag, withoutFlag) => true Two consequences. `getRemoteSnapshot` normalizes everything the relay returns (`remote-workspace-relay-sync.ts:29`), so a client could never receive the flag even though the write path sends `session` verbatim. And `patchRemoteWorkspaceSession` short-circuits on `remoteWorkspaceSessionMatchesSnapshot` (`:51`), which normalizes both sides -- so a change that only sets this marker was invisible to the dirty check and could never be written at all. The marker is the sole guard on `applyDefaultTerminalTabs` (`worktree-default-terminal-tabs.ts:34`), so losing it re-applies the whole default-tab template over the user's tabs. This has never worked in either direction since the field was introduced. `normalizeRemoteSession` predates `372469b752` under a different path -- it lived in `src/main/ipc/remote-workspace.ts` until `15e1ba3f84` split it out for max-lines, and at `372469b752` it already copied all four sibling optional fields and simply never gained a fifth. No test caught it because that commit tested the projection round-trip (correct in isolation) while the normalizer sat in another file with no test of its own. The new test file closes that gap two ways: a case per consequence, plus a `Required<RemoteWorkspaceSession>` fixture asserted key-for-key against the normalizer's output. Because the normalizer is an object literal, the next field added to the wire type and forgotten there is now a compile error in this test rather than silent data loss. Wire compatibility: safe, but not by the plain "new optional field" rule -- this is an already-declared field that was silently stripped on every read. Both readers treat it as optional; the relay is not a reader at all (`src/relay/workspace-session-handler.ts:145-151` stores `patch.session` as an opaque record and returns it verbatim), so no relay version can strip it. A half-upgraded pair is one-directional: an old client still drops the key on read and behaves exactly as today. Before: 3 failed | 1 passed. After: 4 passed.* fix(ssh): a host tab row this client cannot place is unverifiable, not absent A degraded listLineage leaves worktreesByRepo empty, so exactTargetWorktreeIds returns nothing, every host path fails to resolve, and importRemoteWorkspaceSession silently dropped every tab row. The apply then marked the target hydrated and 'synced', freezing that emptiness in permanently — nothing re-pulls a hydrated target (STA-3593). The importer now reports unplaceable rows, the apply claims authority only when every row landed, and a bounded chain re-pulls the missing input (catalog + lineage). On exhaustion it settles back to the pre-fix behaviour so a genuinely unplaceable path is never left worse off than today. * fix(ssh): keep the re-pull chain bounded, unwedgeable, and announced once Five defects in the first cut of the chain, all found before merge: 1. the caller owned the attempt counter, so an unsolicited host push re-armed it at 0 and the chain never exhausted - an unbounded workspace.get loop; 2. exhaustion never cleared the counter, so after one bad connection every later reconnect re-exhausted instantly and the retry was silently dead; 3. the exhaustion check preceded the armed-timer guard, letting a concurrent report cancel the still-pending final retry; 4. a rejected host read left no timer armed and nothing rescheduled, stranding the target on 'pulling' and un-hydrated forever - and an un-hydrated target never uploads again, the exact permanent degradation this design avoids; 5. exhaustion re-announced on every later report, re-marking hydrated and rewriting status on each host push. The module now owns the counter, resetTarget gives each connection a fresh chain, the armed guard precedes exhaustion, the timer body always reschedules so any failure walks to exhaustion, and exhaustion is announced once. * fix(ssh): never authorise uploads from a picture known to be incomplete Reversal of this branch's own exhaustion fallback, on evidence. Hydration authorises uploads (use-app-session-persistence.ts), and an upload is a workspace.patch of kind 'replace-session' (remote-workspace-relay-sync.ts:66) which wholesale replaces the host snapshot (relay/workspace-session-handler.ts). So marking a target hydrated on a picture we know is missing rows does not 'settle back to the old behaviour' - it uploads an empty projection that DELETES the host tabs we failed to adopt. Suppressed uploads are recoverable; a wiped host snapshot is not. That data loss is reachable on main today, because today the apply marks hydrated immediately. Exhaustion therefore reports 'error' and leaves the target un-hydrated, so terminal authority stays 'unverifiable' and no upload can be built from it. Also closes two chain-lifecycle gaps found in review: - the callback dropped its timer guard before awaiting the host, leaving a gap in which a concurrent report armed a second overlapping chain and could trip exhaustion before the pending apply resolved; an in-flight guard now spans it; - resetTarget could not cancel a callback already past its await, so a stale one rescheduled on top of the new connection's chain; chains are now generation stamped and a stale callback exits. * fix(ssh): scope re-pull in-flight ownership to a generation Two races found in review of the previous commit: - schedule(target,'placed') cleared timer/count/exhaustion but did not invalidate an apply already in flight. When that apply later resolved 'unplaced' its generation still matched, so it started a fresh chain from attempt 0; a host repeating placed pushes during each in-flight retry could reset the budget indefinitely. Retirement now bumps the generation too. - the in-flight marker was a bare Set, so a superseded callback's finally deleted whichever marker was present - including one a newer generation had since taken. A later report could then arm an overlapping timer while that newer apply was still running. Ownership is now a target -> generation map and a callback releases only the marker it still owns. resetTarget deliberately no longer drops the marker: its owner is the only party that may release it, and clearing it there would let a new chain arm while the superseded apply is still running. * fix(ssh): replay an unplaced report that was blocked by a superseded apply Regression from the previous commit. Keeping the stale in-flight marker across resetTarget stops overlap, but it also swallows the new connection's result: the new apply reports 'unplaced', hits the guard because the superseded apply still owns the marker, and the superseded callback then exits on its stale generation without scheduling. Nothing replayed the dropped report, so no chain started - the retry silently never ran for that connection. A blocked unplaced report is now recorded, and the marker's owner replays it on release. Only the stale path reaches the finally still owning the marker, so the normal path - which released early and scheduled its own outcome - cannot replay twice. * refactor(ssh): drop the re-pull retry chain, keep the fix The chain produced ten defects across review - unbounded retry, dead retry, cancelled final attempt, wedged chain, repeated exhaustion, overlapping chains, stale-callback cleanup, a lost report - every one in code that passed the full suite at the time. It bought only faster recovery *within* one connection: syncAfterConnect and applyUnsolicitedSnapshot already re-pull on the next connect or host push, so dropping it costs a retry, never the data. What remains is the part that was correct from the first commit: the importer reports rows it could not place, and an apply that could not place them neither marks the target hydrated nor sets 'synced'. Because hydration is what authorises uploads, and an upload wholesale replaces the host snapshot, that single rule is what stops a client from deleting the host tabs it failed to adopt. Status is now 'error' rather than 'pulling': with no chain pending, 'pulling' claimed a request that was not in flight. * test(ssh): name the upload-suppression case for the chainless design * fix(ssh): revoke stale hydration and keep authority unverifiable when unplaced Two holes in the previous commit, both found in review. The hydrated set is add-only (ssh.ts), so withholding hydration only protects a target that never synced. A target that synced cleanly and then reconnected with a degraded lineage kept its flag, and hydration is what authorises uploads - so it would still send a replace-session patch built from the incomplete picture and delete the host tabs it had just failed to place. Hydration is now revoked, not merely withheld. The status phase was 'error'. workspace-terminal-host-authority.ts treats 'offline'/'error' on an un-hydrated target as its bounded floor and resolves them to 'none' - which authorises seeding AND sleeping-agent resume, the exact double-resume this gate exists to prevent. 'conflict' is the phase that actually describes the situation, is excluded from uploads by use-app-session-persistence, and is deliberately outside that floor set, so authority stays 'unverifiable'. Both invariants are pinned by tests verified to fail when either fix is reverted individually; the pre-existing tests passed with both reverted. * fix(ssh): drop the mismatched message on the unplaced conflict status The phase drives the user-visible label ('Workspace sync conflict'); carrying an 'unavailable' message alongside it only risked contradicting that wherever the message is surfaced. * docs+refactor(ssh): correct the authority floor's premise, drop a dead wrapper Two findings from the post-merge correctness sweep. The bounded floor in workspace-terminal-host-authority.ts justified itself on 'remoteWorkspaceHydratedTargetIds is add-only, clearRemoteWorkspaceHydrated has no production caller'. This branch adds that caller, so the premise is now false and a future reader would have been misled by it. The comment records the real consequence: a target that later lands on offline/error reaches the floor having demonstrably answered, so seeding is authorised over live host terminals. Not a regression - before revocation existed the same target was marked hydrated and synced, reaching 'none' sooner - but the floor should learn to tell a revoked target from one that never answered. Flagged for the SSH-v3 consolidation, where one authoritative liveness source replaces this pair. applyUnsolicitedSnapshot had become a pass-through to applyPreparedSnapshot, carrying a docstring about a re-pull chain that no longer exists. The two collapse back into one function. * refactor(ssh): delete the DirectSshSnapshotPlacement union Consolidation pass finding. The union was exported and threaded through two modules, but no production consumer ever read it: remote-workspace-ipc-bridge.ts discards the promise's value and syncAfterConnect ignored it. 'not-applied' was not a placement at all, only 'this apply did not happen'. That is a parallel verdict concept with no consumer - precisely what the SSH-v3 consolidation would have had to unpick. It collapses to a local hasUnplacedTerminalTabs boolean and a void return. The one test that asserted the return value now asserts adoption instead, which is the observable outcome rather than a proxy for it. All five unplaced oracles still fail when the placement decision is forced, verified individually. * docs(ssh): compress the tombstone rationale to its load-bearing WHY Elegance pass. Kept the two non-obvious claims - absence cannot distinguish 'never told' from 'user closed', and uuid tab ids make a tombstoned id safe to drop - and cut the incident narrative around them. The twice-reverted history in remote-workspace-session-merge.ts is deliberately left alone: that one is institutional memory about regressions, not restatement of the code. * test(ssh): pin the fixed behaviour instead of the defect it replaced The spec was a characterization test whose own title said 'because hydration is marked even when adoption wrote nothing', and whose comment described exactly the defect this branch fixes: markRemoteWorkspaceHydrated ran unconditionally after the hydrate calls, so in the same tick adoption yielded zero, authority flipped unverifiable -> none, and Terminal.tsx seeded a phantom tab. It polled for hydrated === true, so the fix turned it red. It now asserts hydrated === false, phase === 'conflict', and zero tabs - the count measured before asserting rather than assumed, confirming the phantom seed is gone. The phase is re-read after the tabs settle and asserted a second time, because a conflict verdict a later apply flipped back would silently re-authorise seeding and a single poll would miss it. The fixme stays a fixme: this branch stops the client claiming false authority and overwriting the host, but adoption is still the open gap. Declining to seed is a safe wait, not the destination. Three-legged A/B against fork pointc72afda498, spec byte-identical across the first two legs: branch + updated spec -> PASS baseline + updated spec -> FAIL 'never reported the unplaced snapshot as a conflict' baseline + original spec -> PASS (baseline actively exhibits the old behaviour)* Fix draft review sidebar actions * Drop unused React import in draft actions test The automatic JSX runtime makes the default React import dead, and tsconfig.tc.web.json failed the branch on TS6133. * Add localization keys for draft review actions The new Ready for review controls introduced five untranslated keys and the static analysis job requires them present in en.json. * Name the draft action for what it does The button read 'Ready for review', which states a status rather than an action, directly under a header already showing the PR state. The i18n key (markReady), the in-flight label ('Marking ready...') and the success toast ('marked ready for review') all already used the verb.Pure rename, no behavior change. Callers like isCommandAvailable and isCommandOnPath wrap this in try { ... return true } catch { return false }, collapsing "distro unreachable" and "command absent" into the same value — a recurring bug class in this subsystem (see docs/reference/wsl-probe-failure-semantics.md). The OrThrow suffix makes that swallow visible at the call site instead of implicit in the function name, so a reviewer notices when a new caller does the same collapse.* test(codex): pin Codex read-repair with a real-binary contract check Orca's session index-heal depends on a Codex behavior: a `thread/read` of an unindexed rollout performs a read-repair that inserts the `threads` row. All 55 existing heal tests drive a stub app-server and assert "healed" as "the call did not error", so if Codex ever dropped the repair they would all stay green while the subsystem went silently inert. Adds a real-binary contract check built to the same shape as the Git binary compatibility contract (src/shared/git-binary-compatibility.test.ts): env-gated test file, version asserted against the binary, dedicated path-filtered PR job. Pins only the four arms ablation established Orca relies on: - a read of an unindexed rollout inserts the state row - a session with no read inserts nothing (the negative control that makes the insert causal rather than incidental) - re-reading an indexed thread inserts nothing - an archived thread stays archived rather than being resurrected Written against codex-cli 0.150.1. The job sets ORCA_CODEX_CONTRACT_REQUIRED=1 so a missing or failed CLI install fails red instead of silently skipping. Existing heal tests are unchanged. * test(codex): register the contract job in the verify aggregate contract `pr-workflow-parallelism.test.mjs` pins `verify.needs` exactly, so adding the job to pr.yml without updating that list failed the shard. Adds the entry, and adds a workflow contract test mirroring `git-binary-compatibility-workflow.test.mjs`: - the pinned CODEX_CLI_VERSION is the single source for both the npm install and the runtime version assertion, so the two cannot drift apart - the install prefix and the binary path the test is pointed at are the same tree - ORCA_CODEX_CONTRACT_REQUIRED=1 is set, so a failed install fails red rather than turning the job into a green no-op Removing the REQUIRED env from pr.yml reddens the new test, confirming it is live. * test(codex): make binary version guard exact and bounded * ci(codex): cover index-heal transport dependencies * test(ci): pin Codex contract dependency coverage * test(codex): align contract watchdog with child deadlines * test(codex): cover three-session contract watchdog * fix(codex): add sqlite sync-database to index-heal scope --------- Co-authored-by: Merge Sim <sim@local>- Jira rejects a bare string for reporter/user-picker fields on issue create, so shape customFields values into {accountId}/{name} objects for keys the caller flags via userFieldKeys. - Seed required user fields with the authenticated viewer by default and add a searchable user picker (jira.searchUsers) so users aren't forced into free text for reporter/custom user fields.* fix(worktree): gate agent activation on the live surface census, not renderer state (STA-5701) * fix(worktree): seed a pane when the surface census cannot prove ownership (STA-5701) Failing closed must not also fail silent. When the census is unverifiable the sweep adopts nothing and mints nothing, yet the gate still reported 'adopted' — and both callers suppress their own seeding on any outcome but 'empty', so the workspace ended with zero surfaces. The sweep now reports whether any live PTY holds a surface and the gate hands the caller its seed when none does. Also folds equivalent workspace-path spellings in the census index and in exact-surface binding, so a host row spelled differently is neither dropped (mint a duplicate) nor unbindable (no pane). * fix(worktree): name the live PTYs the surface census declined (STA-5701) The adoption sweep can leave a live PTY without a surface — an unreadable census, two host surfaces claiming one PTY, or a host-named leaf the persisted layout does not have. The gate already stops reporting 'adopted' in that case so the caller seeds a shell, but the decline itself was mute. - adoptLiveWorkspacePtySurfaces now returns { surfaced, declinedPtyIds } and the gate warns with the workspace and the PTY ids left unsurfaced. - Pin the host-named-leaf decline, which had no test either way. - Pin the superseded-inventory race in terminal.list: a concurrent refresh makes hostScope.hostIds empty, which is what makes the renderer's 'unverifiable' verdict reachable on a plain local machine.Root cause: the test cleared the injected tail-reader failure *before* writing the recovered transcript line. The capped rotation retry loop is still firing at that point, so a retry drain could succeed against the still-empty file, consume the pending initial drain, and emit an empty initial snapshot (`[], false, 0, undefined, undefined`). The later manual watch callback then took the append path, and `u-recovered` never reached onInitialSnapshot -- producing the CI failure `expected [ false, +0, ...(5) ] to deeply equal ArrayContaining{...}`. That empty-snapshot-then-append sequence is correct product behavior, so this is a test bug: write the content first, then clear the failure, so no drain can ever observe a readable-but-empty transcript. The assertion now checks the exact recovered snapshot instead of a flattened arrayContaining, so an empty recovery snapshot fails loudly.* test(updater): stop a slow module import from failing the next test `updater.ts` is 2.4k lines. Its first transform in a worker costs ~1.4s idle but 45s+ when the machine is oversubscribed, which is past the 30s `testTimeout`. Vitest cannot cancel the timed-out test body, so the abandoned continuation went on to call `setupAutoUpdater` during the *next* test — with the harness already reset — and failed it with: AssertionError: expected "vi.fn()" to be called 1 times, but got 2 times That is the exact signature of the abandoned-instance timer flake fixed in #17649/#17663, so a machine-load timeout reads as that regression returning and sends the reader hunting in the wrong place. Two changes, in `updater-test-module-loader.ts`: - `loadUpdaterModule()` replaces every `await import('./updater')` in the suite. It records the test that asked for the module and throws if the import resolves after that test ended, stranding the continuation so the timeout stays the only reported failure. This removes the trap. - `warmUpdaterModule()` imports the module once in `beforeAll`. The transform is cached across `vi.resetModules()` — only a file's first import pays it — so warming moves that one slow import onto the 60s `hookTimeout` and leaves every in-test import at re-evaluation cost (~25ms idle). Measured on a 16-core mac, first vs later import in one file: 1439ms / 25ms idle, 8339ms / 149ms under 40 CPU hogs, 45521ms / 15182ms under 400. Under 400 hogs the suite went from 15 files and 22 tests failing (15 timeouts plus 7 misleading assertion failures) to 23/23 files and 269/269 passing. Under 900 hogs it degrades into 14 plain `Hook timed out in 60000ms` failures and zero assertion failures. * fix: tighten the fence, surface its warning, stop patching timers on warm-up Review findings on the loader: Drop trackRealTimers() from warmUpdaterModule(). It was inert — updater.ts arms no timers at module scope — and actively harmful for the 5 files that build their own mocks and never call clearTrackedRealTimers(). Those files previously had pristine timer globals; the warm-up installed a wrapper that was never restored and whose armed-handle set grew unbounded. Key the fence on TestRunner.getCurrentTest() instead of currentTestName. Nothing ever clears currentTestName, so the fence only fired once the *next* test had started; a continuation resolving during the timed-out test's own teardown, or after the file's last test, was still handed the module. The last-test case mattered: the harness afterAll has already cleared timer tracking by then. Emit the diagnostic through process.emitWarning. The throw lands on a promise vitest already settled, so the message explaining why the continuation was stranded was discarded and reached nobody — which was the entire payoff. Widen the loader test's race margin 50ms -> 500ms. It gated on the test-to-test transition completing in 50ms, so the regression test for a contention bug could itself fail under contention.* perf(terminal): let a worktree's terminal spawns run concurrently The per-worktree terminal mutation guard was a FIFO mutex, so activating a multi-tab worktree made each tab wait for every predecessor's whole spawn. Measured with ORCA_PTY_SPAWN_TIMING=1 on a 4-tab worktree, the `options` phase was a pure-queueing staircase: 0 / 125 / 212 / 291ms. The invariant that guard protects is spawn-vs-sleep exclusion, never spawn-vs-spawn. Replace it with a writer-preferring shared/exclusive lock: spawns share, sleep still excludes, and a queued sleep blocks later spawns so a stream of spawns cannot starve it into its 12s deadline. Same worktree after: options = 2 / 3 / 12 / 34ms, and stable_adoption flattens from 65/398/398/312ms to a steady ~253ms. The existing folder-workspace control assertion asserted the FIFO behavior this removes, so it is re-based on sleep-vs-spawn (which still queues) and joined by a case asserting concurrent same-worktree spawns. * perf(terminal): memoize the headless snapshot per mutation epoch Attaching a viewer serializes the session's whole headless buffer synchronously on the daemon event loop, so every reattach of a quiescent session re-serialized identical bytes. Measured with ORCA_PTY_SPAWN_TIMING=1, stable_adoption was 253-281ms per session on reattach. Move snapshot assembly into HeadlessSnapshotCache and memoize its expensive parts (the serialize, the OSC link walk, the frame-restore fields) on a mutation epoch that every emulator state mutation bumps, so a cache hit is byte-identical by construction rather than merely fresh-enough. The async write path bumps on entry and again in the parse-completion callback, so a snapshot taken mid-parse can never be retained. Reattach of a quiescent session after: stable_adoption 12ms. Sessions with output since their last snapshot re-serialize exactly as before. Retention is capped: an entry is held for the session's lifetime once it goes quiescent, and a renderer may request 50k scrollback rows, so oversized payloads serve normally but are not retained. Cache hits clone nested values so a caller mutating its snapshot cannot corrupt later ones. * perf(terminal): keep the snapshot cache warm across zero-byte parse fences Review follow-up. flushParsedWrites() is write(''), used purely as a parse fence, and every getSettledSnapshot runs one — so the epoch bump on an empty write evicted the attach entry on each checkpoint read, defeating the cache for any session that gets checkpointed. Zero bytes cannot mutate the buffer: the OSC and mouse-mode scans are no-ops on '' and the partial-escape tail is idempotent, so skip the bump for empty data. Real writes still bracket themselves, and any write a fence orders behind has already bumped on its own completion. Also invalidate on dispose, so a post-dispose read can never be served a pre-dispose entry, and freeze the emulator's public method surface in a test: the cache's correctness rests on every mutator calling markMutated(), which is convention rather than a type, so a new method should be a deliberate decision about invalidation instead of a silent stale-snapshot bug. * refactor(terminal): apply elegance review to the attach-latency fixes Reuse: waitForMutationGrant hand-rolled the deadline race that settleBeforeDeadline (same directory, four existing callers) already owns. Using it also picks up the timer.unref() the local copy lacked, which was keeping the Node event loop alive for up to the 12s sleep deadline. Simplify: drop the waiter `abandoned` flag. The timeout path sets it and splices the waiter out in the same synchronous block, so no queued waiter can ever be observed abandoned and both reads were unreachable. The splice is what actually does the work; the comment now carries why that makes a grant-after-timeout unrepresentable. drain() then collapses into its loop condition and reuses markActive() instead of inlining it twice. Extract markWritten() so the zero-byte parse-fence rationale lives at one mutation gate instead of being restated at three call sites. Match the daemon's byte-accounting convention: the retention cap is now expressed in bytes with code-unit sizing, like MAX_COLD_RESTORE_CACHE_BYTES next door, so the two retention budgets read in one unit. Same effective cap. The public-surface guard test caught markWritten on the first run, which is the behavior it was added for. * perf(terminal): stop discarding the snapshot cache on non-memoized fields Second elegance round, and it found the same class of bug as the parse-fence one: cwd and lastTitle are read fresh on every build and were never memoized, yet setCwd/setLastTitle bumped the epoch — discarding a whole serialize to update a field the cache does not hold. OSC 7 cwd updates land on every `cd`, so this was a live cost on exactly the busy sessions the cache targets. The invariant is "every mutation of a memoized part", not "every state mutation"; both docblocks said the latter and are corrected. Drop the dispose bump too. A post-dispose getSnapshot re-serializes the disposed terminal to byte-identical content, so the bump bought nothing and only reached into a disposed xterm — verified by probe, not assumed. Its test asserted zero serializations after dispose, which no implementation could violate; it passed with the bump deleted. Also memoize rehydrateSequences (a string, so no clone needed) instead of rebuilding it on every hit, derive the frameRestore type from buildFrameRestoreSnapshotFields so a new field cannot flow through at runtime while the type omits it, inline the single-caller resolve() into build(), and move the write() entry bump after the sync early-return so the three bumps map 1:1 onto sync / async-pre-parse / async-post-parse. The surface guard is sorted in source and renamed to say what it freezes: the prototype, TS-private members included. * fix(terminal): correct the fence justification and key the cache by window The markWritten docblock claimed "zero bytes cannot mutate the buffer". That is false, and I verified it: `_core.writeSync('')` drains xterm's pending queue and applies it. The exemption is still correct, but for a different reason — a fence cannot introduce an *unattributed* mutation, because any bytes it drains belong to a queued async write whose own completion callback bumps first. The two write regimes are exhaustive: with writeSync present nothing can queue, without it every write is async and self-bumps. A comment asserting a false invariant is worse than no comment, since the next change may rely on it, so it now states the real one. Key the cache by scrollback window instead of a single slot. Consumers ask for different windows against the same emulator — attach passes the full window while agent/text reads pass 0 — so one slot thrashed to a 0% hit rate whenever they alternated, silently removing the benefit on runtime-side emulators. Two entries cover every caller pair in the tree. Drop the epoch counter: markMutated already nulls the retained entry and an entry is only ever stored under the current epoch, so the comparison could never fail. Invalidation is simply "clear the cache". * refactor(terminal): name the lock sides shared/exclusive for a third caller Rebasing onto main surfaced a semantic conflict the merge applied cleanly: main added runWorktreeTerminalMutation (terminal orphan adoption, #17159) as a third caller of the guard this PR changed. It needs the exclusive side — adoption reconciles a worktree's terminal records, so it must not interleave with a spawn registering a pty or with a sleep, which is exactly the semantics it was written under when the guard was a plain mutex. With three operations, naming the sides after two of them no longer fits, so the kinds are now `shared` (spawn) and `exclusive` (sleep, adoption) — what they do rather than who calls them. * perf(terminal): do not invalidate the snapshot cache on a no-op resize Re-measuring the final rebased build caught the cache barely working on the path it exists for. Every attach re-asserts the pane's dimensions, and resize() bumped unconditionally, so a reattach of a fully idle session missed its own cached snapshot and re-serialized. Measured on the same 4-tab worktree, reattach of sessions verified quiescent (no buffer change over 4s), stable_adoption per session: before this commit: 98 / 382 / 395 / 418 ms after: 16 / 67 / 78 / 87 ms A resize to the size already applied changes nothing the snapshot reads, so it now returns early — which also stops it clearing restoredOscLinks, correct since no rows shifted.On Windows with no host `glab.exe`, the cwd-less `glab auth status` known-hosts probe fell through to `wsl.exe -d <default distro>`. Probe failures are never cached, so when that WSL leg also fails (glab absent or logged out inside the distro) every forge detection re-booted the distro; when it succeeds it cached that distro's auth hosts under the 'native' execution key, which the comment two lines above the call already forbids. gitlab-auth-and-rate-limit.ts already passes allowDefaultWslFallback: false for this exact command; the known-hosts probe now agrees with it. Connection-keyed probes keep the fallback: glab has no SSH/relay dispatch, so the `glab api` calls this gates run the same local CLI with no cwd and would otherwise disagree with the probe. wsl:<distro>-keyed probes were already unreachable by the fallback, so passing the flag there is inert. Counted child_process.execFile calls over 3 sequential probes, process.platform forced to 'win32', host glab mocked ENOENT (wsl.exe / glab.exe spawns): native key, glab absent in the distro too: 3/3 -> 0/3 native key, glab logged in in the distro: 1/1 -> 0/3, and that distro's self-hosted hosts stop reaching the native known-hosts list connection key: 1/1 -> 1/1, unchanged Residual risk on that second config: a repo on a \\wsl$\ UNC path can be keyed 'native' (no project runtime match) while its own glab calls still route into WSL by cwd, so it loses the seeded host and must re-derive it through `glab auth status --hostname`. A co-resident Windows-path repo on the same host can now write a shared `native\0<host>` unauthenticated negative that stalls that recovery for one NEGATIVE_ENTRY_TTL_MS window. Fixing that properly means keying the cache by the host that actually served the call, which needs an exec-layer API change and is deliberately out of scope here. Also splits the getGlabKnownHosts suite out of gl-utils.test.ts (796 counted lines against the 800-line cap for tests) into gitlab-known-host-probe.test.ts.The speculative warm-up that runs while the create composer is open resolved refs and fetched with host Git even when the project's runtime is a WSL distro, while both the checkout preparation it feeds (`prepareWorktreeCreateForRepo`, which already resolves `{ wslDistro }` itself) and the real create path run inside the distro. The concrete cost was a discarded fetch: `getCanonicalFetchKey` namespaces the runtime's remote-fetch cache `wsl:<distro>` vs `local`, so the warm-up's fetch landed in a namespace create never looks at, and create fetched again. On a Windows host with no usable host-side Git the probes also failed outright, so that cohort got no warm-up at all. Thread the project's worktree Git options through the prefetch (resolved by a non-throwing helper, because an optimistic warm-up must not surface a repair-required runtime as a failure) so every probe and fetch runs where create runs. `gitOptions` is a required argument, so a caller cannot drop the routing silently. Host-routed calls keep their original arity, so macOS, Linux, native-Windows-host projects, SSH repos and folder workspaces are unchanged. Narrower than it looks: for a repo under \\wsl.localhost\<distro>\... the probes were already routed by cwd, and for a repo on a Windows drive letter host Git and WSL Git read the same on-disk repository, so the answers were already correct there. What those cohorts gain is a fetch create can reuse; what they pay is that the probes now run inside the distro (over /mnt/c for drive-letter repos, which also newly arms the linked-worktree routing probe) and the speculative fetch now shares create's per-remote fetch queue, as it always has on native platforms. Also collapse the three byte-equivalent copies of `hasLocalWorktreeBaseRef` (create, prefetch, remote-repo create) into one in git/worktree-base-ref-probe.ts, drop the host-only `hasLocalCommitObject` that caused the routing bug, and add the first routing assertions on the create-path consumers of the now-shared probe.`computeWorkspaceRoot` resolves a WSL repo's mirror root through `getWslHome`, which is a synchronous `execFileSync('wsl.exe', ...)` with a 5s timeout. Two worktree preparation paths ran it on the Electron main thread: `prepareLocalWorktreeRootForRepo` (repo registration, clone completion, repo update, project host setup, folder->git upgrade) and `prepareWorktreeCreateForRepo` (the speculative checkout started while the create composer is open). On a stopped or cold distro that froze every window for up to 5s. Being fire-and-forget did not help: only 3 of the 16 `prepareLocalWorktreeRootForRepo` call sites are `void`-ed, the other 13 are awaited inside IPC handlers, and the sync probe blocks the main thread either way. `prepareLocalWorktreeRootsForRepos` runs the same probe for every repo from the settings-save handler. Adopt the existing `computeWorkspaceRootAsync` (now exported) at those two call sites, and give the two resolvers a shared mirror-distro decision and shared root-from-home layout so they cannot drift apart. Also thread the mirror distro into the prepare-side path settings. `createLocalWorktree` passes `getWorktreeMirrorDistro(store, repo)` and `prepareWorktreeCreateForRepo` did not, so a `C:\` repo on a WSL project runtime prepared under `C:\workspaces` while the create click looked under the mirrored WSL root: the keys never matched and every prepared checkout was discarded, after paying for a full checkout that sat until the 5min TTL. Pre-existing on main; included because it is the same line and the same resolver. Scope of the win, stated precisely: only those two preparation paths stop blocking. On a reachable distro `getWslHome` caches on success, so before this change the first repo paid one blocking probe and the rest were cache hits -- the change makes that one probe non-blocking, it does not remove N probes. Failed probes are never cached, so on a stopped distro N repos did pay N sequential 5s blocking probes and now share one in-flight async probe. Costs: five sync `computeWorkspaceRoot` callers remain (allowed-roots resolution, the create click in worktree-remote, CLI create, watch targets, worktree trash), and `getWslHome` reads only `wslHomeCache` -- it cannot join an in-flight async probe. The guaranteed synchronous cache warm-up therefore becomes a window in which one of those callers can still block and can spawn a second concurrent `wsl.exe`. Concretely: opening the create composer and clicking Create within a few hundred ms on a cold distro now pays the freeze on the click instead of on the background prep. Separately, the mirror-distro fix makes prepare spawn an async `wsl.exe` home probe for `C:\` repos on a WSL runtime, where it previously spawned none. No race added: `prepareWorktreeCreateForRepo` computes the preparation key and inserts the registry entry in one synchronous run after the await, so two concurrent creates still dedupe to a single prepared checkout. `worktree-create-preparation-wsl-root.test.ts` runs the real resolver through prepare and then claims the entry with the production consume-side call shape (including the mirror distro), so a divergence between the two resolvers fails a test instead of silently discarding every prepared checkout.Two small changes to the Git metadata read path. Neither has a user-visible effect on any platform except for a malformed `.git` gitfile, described below. 1. resolveGitMetadataPath's third parameter becomes an options object `{ platform?, wslDistro? }`. A caller that knows which distro wrote a pointer can now say so, where previously only a WSL UNC base path could. The distro encoded in the base path still outranks the caller's, and translation only happens when the reading host is win32, so a caller-named distro cannot make a POSIX host fabricate a Windows path. The UNC-base branch is exempt from that gate because that spelling only exists on Windows. Main's other contracts are verbatim: never null for a non-empty pointer, and a drvfs pointer keeps its drive spelling even when a distro is named. Both production call sites (repo-git-marker-scan.ts) pass no options, so they are unchanged. 2. The `.git` gitfile marker parse moves into one shared function, parseGitdirMarkerPayload: `gitdir:` at the start of the file, payload trimmed, empty payload rejected — git's own read_gitfile_gently rule. resolve-git-dir.ts and repo-git-marker-scan.ts both call it; the latter had a near-identical private copy and is behaviorally identical after the swap (verified across twelve marker spellings; the only divergence, a whitespace-only payload, already resolved to null one call further down). Main's `/^gitdir:\s*(.+)\s*$/m` in resolve-git-dir captured trailing padding into the path and honored a `gitdir:` line anywhere in the file. Per-platform delta: none on macOS, Linux, native Windows, WSL, SSH, relay, or folder workspaces. The wslDistro option is inert; this change adds no caller. For a malformed `.git` gitfile, padding is now stripped (strict improvement), a whitespace-only payload falls back to `<worktree>/.git`, and a `gitdir:` line that is not the first line is no longer honored — a narrowing, since main could return a working gitdir there. All four resolveGitDir consumers already degrade through a catch, so that case reports no sparse state / conflict operation / diff stamp rather than failing. Six other hand-rolled `gitdir:` parsers remain, including the relay's SSH copy; converging them is its own change.`readLocalWorktreeGitDir` resolved a linked worktree's `.git` gitfile pointer by hand, translating a POSIX-rooted pointer only when the worktree path was itself `\\wsl.localhost\...`. On a Windows host `path.isAbsolute('/mnt/c/repo/.git/worktrees/wt')` is true, so a drive-path worktree (`C:\Users\me\wt`) whose gitfile was written by git running inside WSL kept the guest spelling and was joined to `\mnt\c\repo\.git\worktrees\wt\HEAD`. All four probes (HEAD, COMMIT_EDITMSG, ORIG_HEAD, tail of logs/HEAD) missed, so the row's lastActivityAt fell back to the worktree directory mtime and a worktree with recent commits could read as stale in the cleanup browser. Delegate to `resolveGitMetadataPath` (src/shared/git-metadata-path.ts, landed in7f63db7d7a, already used by repo-git-marker-scan.ts). Five lines out, one in; the shared resolver is not modified. Delta, enumerated over 10 base x 18 pointer x 3 platform combinations (540 pairs) against a reimplementation of the removed branch: 28 differ, every one win32 + non-UNC base + `/mnt/<lowercase-letter>` pointer. - WSL-on-Windows: a drive-path worktree with a WSL-written pointer now probes its real git metadata. - WSL UNC worktrees, native Windows, macOS, Linux: no change (0 deltas on darwin/linux, 0 for any `\\wsl.localhost\...` base). - SSH / relay / folder workspaces: no change; remote repos return the persisted timestamp before any probe, and a folder workspace has no gitfile pointer. Not strictly monotone: the old probe target `\mnt\c\...` is a real drive-relative location, so if it existed with a newer mtime than the genuine gitdir this lowers lastActivityAt for that row. The persisted timestamp stays a floor via Math.max, and in every realistic case the change only raises the value. No signature changes, no options threading, no new call sites.* fix(native-chat): keep the attachments on a Claude turn that pasted images A Claude turn carrying pasted images reached native chat with no images at all — no thumbnails on mobile, and not even an attachment chip on desktop. Nothing showed that the message had any. Both carriers were being dropped: - Claude records the paths in a companion turn marked `isMeta`, holding one `[Image: source: <path>]` text block per image. The decoder treats an `isMeta` user row as injected, filters it down to tool-result blocks, and returns null when none remain — so the whole row went away. - The prompt row's own `image` blocks are `{source: {type: 'base64'}}`, which carry no url or path, so `imageRefBlock` drops them too. With the companion gone, `isImageSourceUserTurn` could never fire and the fold in `normalizeImageTranscriptMessages` was unreachable on the Claude path. Surveying every transcript under `~/.claude/projects`: 238 of 241 image-source rows are `isMeta`, across every versioned release (2.1.220 through 2.1.237); the 3 that are not carry no version field at all. 38 of those rows hold more than one content block, which also defeated the single-block rule in `isImageSourceUserTurn`. Let image-source text survive the injected-turn filter, and recognize a turn whose blocks are *all* markers rather than only a lone one. An ordinary injected turn (a skill preamble, a compact summary) is still dropped, and a turn that mixes prose with a marker is still not an image-source turn. Carrying the paths keeps the payload small; decoding the base64 instead would put hundreds of KB per image on the wire to mobile. * fix(native-chat): preserve image companion ordering * fix(native-chat): keep image companions turn-local --------- Co-authored-by: Merge Sim <sim@local>Untracked line counts, and the untracked share of the branch line total, come from direct lstat/open calls rather than from git. When git executes inside a WSL distro the worktree path can be a guest path, which Win32 reads as drive-relative (`/home/me/repo`) or as a literal `C:\mnt\c\repo`. Every lstat then fails, countFileAdditions swallows the error into `{}`, the file renders with no +N, and the branch total silently undercounts it. Route only those two filesystem reads through resolveWorktreeFilesystemPath, which translates a guest-rooted path via the shared resolveGitMetadataPath. Both call sites produce the same string, so the stat-keyed untracked cache still hits across them. resolveGitMetadataPath itself is not modified, so its other callers are untouched. The wrapper translates only when the host is win32 and the path is a guest spelling: exactly one leading slash, and unchanged by trim(). Each condition is load-bearing. - `//wsl.localhost/...` and `//wsl$/...` are UNC spellings that also start with `/`, and translating one prepends the distro root a second time, so a worktree that reads fine today would ENOENT on every untracked file. The same single-leading-slash guard is used, for the same reason, by resolveWslRepoWorktreeBasePath in src/shared/wsl-paths.ts. - resolveGitMetadataPath returns `rawPath.trim()`, so a worktree directory name with leading or trailing whitespace (legal on ext4) would be re-spelt onto a different directory. Leaving those verbatim keeps them exactly as they read today. - Off win32 the resolver is already an identity for these inputs; the platform check makes the macOS/Linux no-op structural rather than derived. Per platform: - Windows + WSL, worktree spelled as a guest path: untracked +N appears and the branch total includes it. - Windows + WSL, worktree already spelled `\\wsl.localhost\...`, `//wsl.localhost/...` or `//wsl$/...`: unchanged, returned verbatim. - Native Windows: `C:\...` is unchanged. One shape does change: a `/mnt/<drive>/...` worktree now resolves to `<Drive>:\...` instead of being passed through to a guaranteed lstat failure. Native Windows does not produce that spelling, and if it were reached the new result is the correct file. - macOS / Linux: unchanged; not win32, returned verbatim, whitespace included. - SSH / relay: unchanged. Remote status runs in the relay, which builds its own branch-total input and does not pass filesystemWorktreePath. - Folder workspaces / GitLab: unaffected, no workspace-kind or provider behavior is touched. No fail-closed degradation: attachLineStats still returns `stagedStats !== null && unstagedStats !== null`, and createBranchLineTotalInput still has no early return. The wrapper returns `string`, never null, so an unmappable worktree keeps its old spelling and its old (missing) untracked counts rather than dropping the staged/unstaged counts as well. The `filesystemWorktreePath` field on computeGitBranchLineTotal is optional and does not touch the lease key, so the coalescing/cooldown identity is unchanged. Co-authored-by: Neil <neil@orca.local>Git running inside a WSL distro writes `.git` gitdir pointers, and answers `status --porcelain`, in the guest namespace. Node reads both back in the Windows main process, where `/mnt/c/repo/.git` resolves to `C:\mnt\c\repo\.git` and `/home/me/wt` names nothing at all. Four fs probes were built on those fabricated paths and always came back "absent": - `detectConflictOperation`'s four marker probes, so merge/rebase/cherry-pick badges silently went missing. - `parseUnmergedEntry`'s compat existence check, so every `deleted_by_us` / `added_by_them` conflict rendered as 'deleted' regardless of the working tree. - `findExistingWorktreeSymlinkPaths`' `lstat` from status, so Orca's own shared symlinks (node_modules and friends) showed as user changes. - the same `lstat` from the hosted-review dirty preflight, which fails closed: an unreadable shared symlink read as uncommitted work and blocked PR/MR creation outright. `resolveGitDir` computes the host spelling of the worktree once and uses it for both the gitfile read and the pointer resolve, so a guest-spelled worktree path is reached at all, and a relative pointer (`worktree.useRelativePaths`, git 2.48+) resolves against a spelling Win32 understands. The pointer itself now goes through the already-landed `resolveGitMetadataPath`, and the function gains an optional `{ wslDistro }` for a caller whose base path does not encode a distro. `detectConflictOperation` forwards it, and the three callers that reach it -- status-read, the runtime RPC, the `git:conflictOperation` IPC -- pass the git options they already hold. The return type stays `Promise<string>`. `resolveWorktreeHostPath` is the same rule applied to a worktree path, used by status-read for the two working-tree probes and by the review preflight. Both it and `resolveGitMetadataPath` now treat only a single-leading-slash path as guest namespace: `//wsl.localhost/...` is already a host UNC spelling, and translating it prepended a second share prefix. `readWorktreeDiffStamp` needed the same one-namespace guarantee, since moving translation inside `resolveGitDir` would otherwise make its HEAD and index real while the working-tree stat stayed fabricated, letting a settled diff survive every edit. #17896 landed that change first, so it is no longer in this diff; its version is a superset and all four components already resolve from one `hostWorktreePath`. What remains here is the `resolveGitDir` gitfile-pointer fix that #17896 explicitly deferred, which `worktree-diff-stamp-host-paths.test.ts` pins. `getConflictCompatibilityStatus` moves from `existsSync` to async `access`, for the same reason `detectConflictOperation` did: once these paths are real they are `\\wsl.localhost\...` shares, and a sync probe per asymmetric conflict blocks the Electron main thread for a 9p round trip on every status poll. Per-platform delta: - native Windows, no WSL: no behavioral change. Nothing here starts with a single `/`, so no path is translated. An absolute pointer is now returned verbatim rather than separator-normalized; every consumer re-joins or normalizes it before use. - macOS/Linux: no change. Guest-pointer translation is gated to win32, and a caller-named distro is ignored off Windows. - Windows + WSL: drvfs pointers and drvfs-spelled worktrees now resolve to their drive spelling instead of `C:\mnt\...`; a non-drvfs guest path resolves through the named distro's UNC share, or stays verbatim (ENOENT -> existing fail-safe) when none is named. - SSH/relay: none. Those paths return before any of this via the provider branch; `src/relay/git-handler-status-ops.ts` keeps its own resolveGitDir. - folder workspaces, GitLab: none. Neither is on these code paths.Two main-process `resolveGitDir` call sites dropped the WSL distro their caller already held, so they could only resolve a gitdir pointer whose spelling carries its own translation: a `//wsl.localhost/<Distro>/...` base, or a `/mnt/<letter>` drvfs pointer that maps to a drive letter on its own. The layout that needs the third case is a repo inside the distro's filesystem with its worktrees on the Windows drive. `git worktree list` reports the worktree as `/mnt/c/wt/x`, which the listing translates to `C:\wt\x` — a base that no longer names a distro — while the `.git` gitfile beside it points at `/home/me/repo/.git/worktrees/x`, which has no drive to derive. Win32 then treats that pointer as absolute and reads a path that names nothing: - `detectSparseCheckout` stats `info/sparse-checkout` under the fabricated path, always misses, and reports the worktree as non-sparse — no sparse badge, and the file list claims files that are not on disk. - `readWorktreeDiffStamp` reads HEAD under the same path, gets nothing, and returns null. Null is the safe answer ("cannot prove unchanged"), but it retires the settled-diff cache for every file in that worktree, so each diff respawns Git. Both callers already have the distro: the listing threads its `GitWorktreeExecOptions` to `annotateSparseCheckoutStatus`, on through `detectSparseCheckoutCached` (the annotation cache added by #17859) and its background revalidation probe, and finally to `detectSparseCheckout`; and `file-diff` already forwards its `GitRuntimeOptions` to `readWorktreeDiffStamp`, which now forwards it to `resolveGitDir` as well. `resolveGitMetadataPath` still prefers a UNC base's distro and still tries drvfs before the caller-named distro, so nothing that resolved before resolves differently. The cache hop matters twice over. It is the only remaining caller of `detectSparseCheckout`, so without threading it the fix would not reach the probe at all. And the cache is where the bug turns sticky. #17859 keyed entries on `repoPath` + `worktreePath` alone, on the reasoning that the distro is a property of the repo and so every read for a given `repoPath` carries the same one. That invariant does not hold. `listRepoWorktrees(repo)` is called with no options at all from the filesystem-auth root rebuild (`registered-worktree-roots-cache.ts`, reached from `ensureAuthorizedRootsCache` on any auth check with a dirty cache) and from the local worktree-ownership check in `filesystem-worktree-helpers.ts`. Both land on the *same* key as the distro-carrying listing, because `translateWslOutputPaths` derives the distro from the cwd spelling before falling back to `options.wslDistro`, so a UNC-spelled repo path yields the identical `C:\...` worktree row either way. Measured on Windows in one process, branch build: a distro-less read followed by a distro-carrying read reported the sparse worktree as non-sparse both times. So `wslDistro` now joins the cache key -- trimmed and lowercased, matching how the rest of the codebase compares distro names, and appended last so the repo-scoped prefix delete still matches every variant. The per-path invalidate becomes a prefix delete for the same reason, dropping every distro variant of a removed or moved worktree. Keying on it closes both halves of the defect. A correct caller can no longer be served an answer derived without the distro it supplied. And because the entry a reader reaches is now selected by the same distro it would re-probe with, `revalidateInBackground` can no longer re-derive a warm entry under weaker options -- which mattered on its own: a distro-less reader crossing the five-minute window would otherwise flip a correct `true` to `false`, and the resulting change notification runs the registered invalidator, clearing the whole repo's cache and re-probing every worktree cold, on a five-minute loop. Cost of the extra key dimension is bounded by the number of distinct distros a given repo is actually read under: one where a distro is threaded everywhere, two while the distro-less callers above still exist. Entries are still repo-scoped, and both clears already sweep by prefix. Per-platform delta: - macOS/Linux: no change. Guest-pointer translation is gated to win32 and a caller-named distro is ignored off Windows; no caller supplies one there, so the cache keys and probes exactly as before. - native Windows, no WSL: no change. `wslDistro` is undefined, so the resolver takes exactly the branches it took before and every read keys on the same empty distro component, so the cache behaves exactly as it did. - Windows + WSL, UNC-spelled worktree: no change. The base already names the distro and outranks the caller's. - Windows + WSL, drvfs-spelled worktree with a drvfs pointer: no change. The drive-letter derivation still runs first. - Windows + WSL, drvfs-spelled worktree with a non-drvfs pointer: the sparse badge appears and the settled-diff cache starts hitting. Both previously failed toward "not sparse" / "do not cache", so neither can now serve a stale answer, and the distro-less listings no longer share the badge's cache entry. - SSH/relay: none. Those paths return through the provider branch before reaching either function. - folder workspaces, GitLab: none. Neither is on these code paths. Not in this change: - `readRepoCommonDirFromDisk` (worktree-listing). Passing the distro there is inert: a repo root's `.git` is a directory, so the gitfile-pointer branch never runs, and when `repoPath` itself is guest-spelled the preceding `stat` already fails — which no `resolveGitDir` option can fix. - The two `findExistingWorktreeSymlinkPaths` calls on the removal paths. Both receive `registeredWorktree.path` from `listWorktreesStrict`, which already translates every row out of the guest namespace, so the distro would be a no-op. The `removeWorktreeLinkedPaths` unlink beside them is untranslated too, so a half-threaded fix would only move the refusal from Orca's preflight to `git worktree remove`. - An absolute `commondir` payload, which `resolveGitCommonDir` still resolves untranslated. Git writes that file relative in the layouts above, and the failure direction is unchanged. - Giving the two distro-less `listRepoWorktrees(repo)` callers a distro. Neither reads `isSparse` -- both use only `worktree.path` -- so the distro would buy them nothing they consume, while resolving a project runtime inside the filesystem-auth rebuild would put a call that throws on `repair-required` behind a catch that skips the whole repo's authorized roots. The cache key makes their reads harmless; skipping the annotation for callers that never read it is a separate, larger change. The third no-options call in `hosted-review.ts` is inside the `repo.connectionId` branch and returns through the SSH provider, so it never reaches this cache.The page had two competing splits: an Aug-25 folder split that the Aug-30 oversized-surfaces split stranded, and the 47 flat files that replaced it. The orphaned tree had no non-test importers, yet eight ratchet files still asserted against it, so their invariants stopped constraining shipping code -- which is how six regressions reached main unnoticed. Those ratchets were repointed and the regressions fixed earlier; this removes the tree they were guarding. Moves the live files into task-page/{github,gitlab,jira,linear} and drops the now-redundant prefix, matching the new-workspace sibling. Makes the source-family walker recursive first: it listed a single flat directory, so moving the files under it would have emptied the family and turned every ratchet built on it into a no-op without failing.Pruning removed 3215 of 3216 files from a superseded generation and always stranded resources/app.asar, leaking ~105 MB per version update. Electron's asar shim reports a *.asar file as a directory, so the recursive remove tried to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it. Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and 519M again once the payload is actually reclaimed. removeExtractedAppImagePayload holds process.noAsar for the removal, counted so overlapping removals cannot hand the shim back early, and the prune site now warns with the path instead of swallowing the rejection. All three removal sites use it -- staging cleanup and displaced roots leaked the same way. Also reclaim symlinks left by a packaged deb/rpm install, which the extracted-cache-only rule turned into a hard conflict on a deb -> AppImage migration, and name the remedy in the conflict error.`pruneMetadataMissingFromAuthoritativeLocalScan` had exactly one caller: `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never ran, and that host's `worktreeMeta` grew without bound even for its own local repos -- 129 of 139 rows dangling on the profile in #17776. Run it from the runtime's own detected listing instead. That is the same trigger on the same evidence: `listDetected` already prunes lineage on an authoritative scan, and a paired client refreshing a remote repo calls `worktree.detectedList`, so the host now sweeps exactly when the desktop would have. The expectation is captured before the scan, because listing can mutate metadata synchronously before its first await. WSL-routed repos are excluded for the reason the desktop listing excludes them: the listing runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove those aliases equivalent. A runtime needing repair throws rather than resolving routing, which is likewise no basis for deleting rows. The prune's own gates still apply, so an SSH- or otherwise off-host repo is never swept from a local stat -- the execution host owns that verdict. Refs #17776A control-handshake test that expects a timeout was instead getting 'Unexpected server response: 401' about once in fourteen runs. A slow machine cannot turn a timeout into a 401 -- that needs a real HTTP response, so the connection was reaching a different server. new WebSocketServer({ port: 0 }) binds the wildcard address while the client dials 127.0.0.1. On macOS those differ, and with SO_REUSEADDR a foreign process can hold the more specific 127.0.0.1:P and win the connection. Caught live: a wildcard bind took port 52584, which a running Orca app already held on loopback, and Orca answered the probe. A listener that checks a token answers 401. Ten constructions across seven files now pass host: '127.0.0.1', so the reservation covers the address the client dials and a duplicate bind is refused. Adds a ratchet, because this is not authors forgetting a convention: all 30+ .listen(0, ...) sites already pass '127.0.0.1', while 7 of 7 ws constructions did not. ws accepts { port } alone and binds the wildcard silently, so nothing told them. The guard pins the wildcard count, and pins separately at zero the option shapes it cannot read -- spreads and variable option objects fail rather than being exempted, and a recognized-construction floor catches the matcher going blind, which otherwise reads exactly like a clean tree. mobile/scripts/mock-server.ts stays on the wildcard deliberately: a phone reaches it over the LAN.* test(ci): ratchet Windows-gated tests into both registration lists PR CI has one windows-2022 job running a curated explicit file list. Every other job runs on ubuntu, where a Windows-gated suite self-skips and reports success -- so an unregistered Windows-gated file executes on no machine and passes green with nothing to tell the author. Scans every test file for the win32 suite-level gate spellings in use plus the .win32.test.* filename, and asserts each one appears in BOTH the "Test Windows-specific boundaries" vitest argv and WINDOWS_PACKAGE_TESTS: the classifier decides whether the job runs, the argv decides whether the file runs. The eight already-unregistered files on main are held in a shrink-only debt list. * fix(ci): detect compound win32 gates in the lane-registration ratchet The gate matcher anchored its argument on the closing paren, so `runIf(platform === 'win32' && hasAddon)` was not matched at all -- the guard excluded real Windows-gated files by accident of a regex rather than by design, and would have missed a compound gate on a file that genuinely needed registering. Match the condition followed by `)` or `&&`, and resolve named flags from their assignment in the same file, so `RUN_REAL = platform === 'win32' && env…` used as `runIf(RUN_REAL)` is detected whatever the flag is called and whichever polarity it was written in. That replaces the hardcoded `isWindows`/`IS_WINDOWS`/`isWin32` names, which guessed polarity from a name; an imported flag stays undetected and is now documented with the live example. `||` compounds are rejected on purpose: they can run off Windows. Ten env-opt-in suites surface as a result. They are win32-gated but also require an `ORCA_REAL_*` env var, so registering them would not make CI run them; they go in MANUAL_OPT_IN, whose entries are asserted to be genuinely compound and env-gated so the list cannot become a quiet parking spot. Also: reuse `scanSourceTree` instead of a fifth divergent walk in the repo (its docblock records the incident where a hand-rolled walk scanned `tests/e2e/.cross-version-checkouts/`), adding an `extensions` option so it can see `.mjs`; strip comments so prose about a gate is not a gate; skip `mobile/`, which `classifyPrJobs` can never report as registered; assert exactly one `windows-2022` job, the premise the guard rests on; cap growth of both grandfathered lists; and test that the self-exemption covers nothing but this file. Corrects two docblock claims that were false: that nothing in the repo computes a gate indirectly (three files did), and that a compound gate's registration was asserted while only its execution was not (neither was). * fix(ci): make the manual-opt-in exemption prove the env read reaches the gate `requiresEnvOptIn` proved the file MENTIONED an env var, not that the gate DEPENDED on one, so `runIf(platform === 'win32' && hasAddon)` in a file that happens to read `process.env.RUNNER_TEMP` parked as manual. That is the native-addon-bytes shape -- a test CI could run -- and only the cap number stood in the way. Now the win32 check must be compound and one of its other conjuncts must read `process.env` itself or name a const that does, which still accepts all ten listed suites. The compound clause guarding that hole was itself unasserted: deleting it left every test green. Two fixtures close it, including an env read on the same line as a bare gate, which is the case that makes the `&&` do work rather than decorate. Split FLAG_ASSIGNMENT by polarity. One shared `&&` lookahead was right for `===` (a second conjunct narrows) and wrong for `!==` (it widens), so `p = platform !== 'win32' && x` used as `skipIf(p)` read as Windows-only though it runs on Windows and on POSIX when `x` is false. The literal form was already rejected; routing it through a flag flipped the answer. Widen the one-lane assertion from a `windows-2022` equality test to any `runs-on` that could land on Windows -- `windows-latest`, a label array, a `{ group, labels }` object -- treating an unresolvable `${{ }}` expression as Windows so it fails closed. Docblock: the case-level count is now deliberately approximate. The reviewer measures 26 against this guard's 31; the figure moves with which gate spellings are counted, and the policy does not rest on it. --------- Co-authored-by: Orca Worker <orca-worker@localhost> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>* fix(tooling): run oxlint gates without a Windows .cmd shim `check:code-quality:changed` spawned `pnpm.cmd` without a shell, which Node refuses under the CVE-2024-27980 mitigation, so the gate died with EINVAL before linting anything. Resolve oxlint's own Node bin and run it under this process's node instead — no shim, no shell, no quoting question — and add a ratchet so the idiom cannot spread back into config/scripts. * fix(tooling): validate the react-doctor diff base and widen the shim ratchet `base` reaches cmd.exe unquoted on the shell fallback, so reject anything outside a git revision before spawning. The ratchet matched only a handful of runner names, which let `vitest.cmd` through even though config/scripts already spawns vitest, playwright and electron-builder; match any batch-shim literal instead, walk subdirectories, and cover tests/tools. * docs(tooling): state what the shim ratchet and diff-base check miss Both comments read as complete accounts of their guard's coverage. The revision class rejects reflog syntax like HEAD@{1}, deliberately, since braces have no business in a cmd.exe-bound argument; the ratchet misses a drive-lettered literal because a colon is not in its class. Say so beside the template-literal ceiling already noted. --------- Co-authored-by: Orca Worker <orca-worker@localhost>* docs(exit-cause): pin why isProvenProcessExit(0) must stay true isProvenProcessExit asks whether the process ended; the cause resolvers ask why. Their disagreement on 0 is the design, not a defect: login(1) wraps every macOS local PTY once the TCC preflight passes, so routing hostReportsChildExitStatus through the predicate would leave every pane a user closed with `exit` mounted forever. No behavior change; comment and regression tests only. * fix(automations): report an unverifiable process loss as lost, not failed Two automation readers consumed the raw PTY exit code with no liveness check, so the -1 unverified sentinel — on SSH, a live relay whose reattach failed — was published as status 'dispatch_failed' with "Automation process exited with code -1." The run was asserted finished when all that happened was that we lost contact. Route both through the existing vocabulary: - The completion tracker records no result for an unproven code. The run keeps its non-final 'dispatched' status, so it is never evicted and never shown as Failed, and stays owned by main's AutomationRunCompletionWatcher, which already reports a genuinely unobservable run truthfully ("lost the terminal for this run") rather than inventing an exit code. finalize() is never reached, so a terminal whose process cannot be proven dead is never closed. A later done can still complete the run. - Both runtime `terminal.wait` readers defaulted an absent status to 0, minting a clean finish out of no evidence. They now share runtimeWaitExitCode, which defaults to the new UNVERIFIED_PROCESS_EXIT_CODE. - The background-session exit handler no longer clears the tab-PTY binding on an unverified loss, matching pty-exit-hibernate.ts, and marks the tab so orphan cleanup cannot sweep an agent that may still be running. A proven exit is unchanged: 0 still completes and finalizes, and a real nonzero failure still reports dispatch_failed.unverifiable(#17972) 6d61305a96* fix(native-chat): keep disabled CLI models out of the Claude picker The Claude CLI advertises models it cannot run yet as disabled placeholder rows. On 2.1.237 `list_models` returns a sixth row alongside the four real models: {"value":"cc-update-required-1","displayName":"Fable 5.1 (disabled)", "description":"Update to 2.1.255+ to use Fable 5.1","disabled":true} `toListedModel` never read `disabled`, and for Claude the discovered list replaces the seed catalog verbatim, so the picker rendered that row as a selectable model and `/model cc-update-required-1` went to the CLI. It was also adoptable as a launch default, putting the sentinel behind `--model` on spawn. Drop disabled rows at the parse choke point, which both the native-chat picker and commit-message model discovery share. The two adjacent fixes are the same version-pinning bug the placeholder announces. `compactTerminalText` strips only whitespace, so a point release keeps its dot and the pinned consent literals (`fable5uses…`, `switchtofable5?`) stop matching a "Fable 5.1" prompt — the switch would degrade to `unknown` instead of `interaction-required`. Likewise the scoped weekly usage window matched `display_name === 'fable'` exactly, so it would disappear once the scope is named "Fable 5.1". Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4 * fix(native-chat): make the Fable consent match version-optional Probing a 2.1.258 CLI shows the shipped Fable 5.1 row carries displayName "Fable" with the version only in the description: {"value":"claude-fable-5-1[1m]","resolvedModel":"claude-fable-5-1", "displayName":"Fable","description":"Fable 5.1 · Most capable for …"} So the consent prompt may name the model with no digits at all. Requiring a version would have missed that, the same way the old pinned literal missed "Fable 5.1". Accept both. Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4 --------- Co-authored-by: Merge Sim <sim@local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>* fix(linux): give the CLI one entrypoint by extracting the AppImage once * refactor(linux): trim AppImage CLI registration seams * test(cli): assert registration lock serialization * fix(linux): fence AppImage terminal shim mounts * fix(linux): accept extracted AppImage runtimes with APPDIR only * docs(linux): make headless AppImage extraction runnable * refactor(linux): import bundled launcher directly * fix(linux): reclaim superseded AppImage payloads and packaged symlinks Pruning removed 3215 of 3216 files from a superseded generation and always stranded resources/app.asar, leaking ~105 MB per version update. Electron's asar shim reports a *.asar file as a directory, so the recursive remove tried to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it. Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and 519M again once the payload is actually reclaimed. removeExtractedAppImagePayload holds process.noAsar for the removal, counted so overlapping removals cannot hand the shim back early, and the prune site now warns with the path instead of swallowing the rejection. All three removal sites use it -- staging cleanup and displaced roots leaked the same way. Also reclaim symlinks left by a packaged deb/rpm install, which the extracted-cache-only rule turned into a hard conflict on a deb -> AppImage migration, and name the remedy in the conflict error. * fix(linux): bound the CLI registration lock wait `retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per attempt an IPC-driven registration could hang ~16 minutes against a wedged holder with no feedback. A legitimate holder is bounded by the extraction timeout, so wait that plus slack and then fail with a message naming the lock file, rather than hanging. `maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile. * fix(linux): stop re-extracting the AppImage on inode metadata churn The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime. ctime moves on any inode metadata write -- `chmod +x`, which every AppImage user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup restore -- none of which alter a byte of the payload. Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime identical and moves ctime alone, so the key changed and the next launch paid a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it already had, then pruned the old generation. Key on content identity instead. An in-place content change moves mtime and almost always size; a replacement moves the inode. The existing replace-in-place test still passes. * fix(linux): stop CLI commands from falling through to Chromium startup * refactor(cli): remove redundant command membership check * test(cli): cover command-named project selectors * fix(cli): redirect the open-url command before startup * test(linux): cover AUR serve wrapper flags * fix(linux): tighten CLI launch detection * fix(linux): respect CLI flag value boundaries * fix(linux): strip injected Chromium switches from CLI args * fix(linux): report a missing display instead of dying in uv_close * refactor(linux): read display locks without a preflight race * fix(linux): preserve unverified external displays * chore: format reliability gate manifest * test(packaging): split runtime resource checks * fix(linux): fail serve when no display is available * fix(linux): do not treat a lockless X socket as a dead display An X server writes its lock beside its socket and both survive a crash (verified against Xvfb under SIGKILL), so a socket with no lock was never left by a crashed server. It is an endpoint published from elsewhere: a container bind-mounting only /tmp/.X11-unix, WSLg, or a foreign PID namespace. Declaring those dead made the desktop gate exit(1) on displays that work, with no workaround, and the serve gate refuse to start. Liveness now splits by ownership. A foreign DISPLAY trusts a lockless socket; Orca's own :99 does not, because removeStaleDisplayArtifacts unlinks the lock before the socket and so manufactures that state itself -- adopting it would resurrect the orphan-socket bug and stop the cleanup from self-healing. The stale-lock rejection is unchanged. Also correct four doc statements this behaviour falsified. * fix(linux): fail closed when a stale socket blocks the Xvfb rebind Readiness only checked that /tmp/.X11-unix/X99 exists. A stale socket we could not unlink still exists after our own Xvfb refused to bind, so Orca set DISPLAY to a dead server and Chromium died in Ozone init. Measured on Ubuntu 24.04 against the pre-fix build: with a leftover :99 socket and no lock, serve exits 139 (SIGSEGV), the socket inode is unchanged before and after, and no lock is recreated -- it neither cleaned up nor respawned. To a user that is a crash, not a misconfiguration. This is reachable in the documented topology, where orca-xvfb.service has no User= and runs as root while serve runs as User=orca: /tmp is sticky, so the orca uid cannot unlink a root-owned socket, rmSync fails, and Xvfb exits with the display already active. Readiness now requires the display to actually be live -- our socket plus a lock naming a running process -- so the same state reports an unusable display and exits 1 with the existing diagnosis. * fix(linux): recognise abstract X sockets and inherited Wayland fds Two display setups this gate could not prove were refused outright, and on the desktop path that is app.exit(1) with no workaround. An X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which leaves no filesystem socket to stat. Abstract addresses are kernel-owned and vanish the moment the owner exits, so an entry in /proc/net/unix is proof of a live server -- no lock file needed and no stale entry possible. Verified on Ubuntu 24.04, where 139 such addresses were present. WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so there is no path to stat and WAYLAND_DISPLAY may be unset entirely. Its presence is the display. Both are consulted only after the filesystem-socket check fails, so no existing verdict changes. * fix(linux): never treat Orca's own display number as a foreign endpoint Recognising a lockless X socket as live is correct for an endpoint published from elsewhere -- a container bind mount, WSLg -- because an X server writes its lock beside its socket and both survive a crash. It is wrong for VIRTUAL_DISPLAY_NUMBER, because Orca's own teardown unlinks the lock before the socket and so manufactures that exact state. The managed branch was already strict, but a caller that sets DISPLAY=:99 explicitly takes the foreign path and skipped it, accepting a dead display left by Orca's own interrupted cleanup. Route the managed number through the strict probe on both paths. Found by an adversarial audit of the asymmetry introduced earlier in this branch; the documented systemd topology is unaffected because its Xvfb writes a real lock. * test(linux): add a packaged-artifact contract for the CLI launch paths * test(linux): avoid buffered serve readiness detection * test(linux): signal AppImage serve owner directly * test(linux): tolerate readiness timeout boundary * test(linux): add startup margin to shutdown oracle * ci(linux): give package contracts timeout headroom * fix(ci): route all Linux packaging contract changes * test(linux): poll shutdown readiness without tail leaks * test(linux): bound shutdown cleanup grace * test(linux): assert on CLI output, not the harness's own control lines run-cli-case.sh echoes `RESULT status=N case=<name>`, and the two cases named *-skills asserted `expectOutput: 'skills'`. That substring was satisfied by the case name in the harness's own line, so 2 of 8 cases asserted nothing about the command -- gutting `skills` entirely would still have gone green. Control lines are now excluded before matching, and both cases assert the rendered help header, which only real help output produces. Verified on an Ubuntu 24.04 host: 8/8 still pass against a stack-tip AppImage. Also register the gate in reliability-gates.jsonc, which #15085 added a CI Docker gate without. Red/green is recorded from a stock release AppImage failing 4 of 8, three of them at status 133 (SIGTRAP). * fix(linux): require static AppImage runtimes (#17319) * test(linux): reject a wrong-architecture native binary at packaging time Cross-building the arm64 slice on an x64 host silently packed an x86-64 `pty.node` -- the rebuild logged "Forcing native rebuild for linux-arm64" and shipped the host's binary anyway. Every gate here inspects symbol versions, which are perfectly valid on the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the packaged app loaded, then failed with "Failed to load native module: pty.node", and the launch contract reported 3 of 8 cases crashed rather than naming the cause. Swapping in the aarch64 `pty.node` took the same build to 8/8. Compare ELF `e_machine` against the slice being packaged and fail with the offending path. Checked before the glibc pass, because a wrong-architecture binary's symbol versions are valid but meaningless and would send the reader down the wrong path. Release CI builds arm64 on a native runner, so this guards local and future cross-builds rather than a shipped artifact. * test(linux): judge per-arch vendored binaries against their own path The first CI run of the architecture gate failed the x64 package job on `@parcel/watcher-linux-arm64-glibc/watcher.node`. That binary is arm64 on purpose: the package ships every architecture and its loader picks the match, so its presence in an x64 build is correct. Judge a binary against the architecture its own path names, falling back to the slice when the path names none. That keeps the case this gate exists for -- `bin/linux-arm64-*/node-pty.node` holding an x86-64 binary, which is what shipped to a Raspberry Pi 5 -- while letting multi-arch dependencies through. Dry-run over the real dependency tree flags nothing for either target arch. * fix(linux): move deb/rpm update installation outside Orca (#17318) * fix(linux): complete deb/rpm package metadata * fix(linux): preserve CLI link during package upgrades * docs(linux): document local RPM build prerequisites * fix(linux): move deb/rpm update installation outside Orca * fix(updater): preserve Linux recovery across stale events * fix(updater): fence stale downloaded events by active target * fix(updater): preserve active Linux package recovery * test(linux): keep workflow order assertion in scope * test(updater): assert stale recovery stays silent * fix(updater): preserve Linux package recovery after checks * refactor(updater): keep Linux marker message with status * fix(linux): describe the right manual update path for deb/rpm hosts A remote host installed from .deb or .rpm now reports manual-service-update-required, and the guidance told the operator to "update through the service manager that starts this server" -- which is correct for unsupported-headless-serve but wrong for a package install, where nothing about the remedy involves the service manager. Say both, keyed on how the host was installed. * docs(linux): document orcad update restart safety * docs(linux): scope restart census omissions * docs(linux): use absolute service CLI launcher * fix(serve): validate in-process serve options before startup (#17683) * fix(linux): stop offering updates a distro-managed install cannot apply (#17918) Closes #17702. The resources/package-type marker is authoritative but never checked against the host, so any repackager that unpacks Orca's .deb -- AUR, Nix, a container rebuild -- inherits `deb` verbatim. Install feasibility was then computed after a ~165 MB download, so those users got check -> download -> a card promising an install command -> a dead end. Validate the marker against the host: a deb/rpm marker with no matching package manager in the trusted directories means a package manager owns this install. This reuses the exact lists and resolver that buildLinuxPackageInstallCommand already loops over, so a false positive is impossible by construction -- any host flagged here would have failed with no-package-manager after the download anyway. The gate only moves that verdict earlier. Verified across Debian 12, Ubuntu 24.04, Arch, Fedora 40 and openSUSE Leap: no false positive on a real deb host, correct on every repackaging host. The release is still reported, because the user does want to know 1.4.194 exists and to update through their distro; only the download path is closed. `externallyManaged` is an additive optional field on the existing `available` status, so older paired clients decode it unchanged. downloadUpdate() refuses authoritatively, since main owns this verdict rather than the card, and unwinds any pinned-build state first -- a Linux pinned jump resolves to 'release', and stranding isPinnedBuildActive would silently kill every background check for the rest of the process. Note the fix the issue suggests cannot work: electron-updater builds a PacmanUpdater whose doDownloadUpdate looks for a .pacman asset Orca does not publish, then dereferences undefined. * style(cli): restore prettier wrapping on install error copy * test(linux): re-pin the child-process ratchets and the batch-shim allowlist after the merge* more obvious toggle * more obvious toggle * feat(activity): redesign thread rows and add child agent filtering - Emphasize task title and last activity in row layout over metadata - Add child agent toggle; hide orchestration workers by default - Support collapsible groups and ungrouped view mode - Improve orchestration worker message handling to surface replies - Add sidebar search and filter controls for agent activity * periodic checkin * feat(activity): add "Clear completed" action and performance improvement - Add "Clear completed" action for activity threads with undo window; clears completed and interrupted rows from view, persists across restart - Virtualize activity thread list to render only viewport-bounded rows - Cache activity thread search text to prevent recomputation on every keystroke - Cache dashboard bucket counts per-worktree for selective invalidation on unrelated changes - Use useDeferredValue for activity search filtering to keep input responsive - Make compact mode the default display for activity threads - Add activity-cleared-at persisted state tracking (per-pane cutoff timestamps) * improve style * minor change * feat(activity): add persisted host and project filters to agents view Agents scope filters are deliberately separate from workspace-nav filters so a monitoring surface never inherits workspace context silently. Filters survive restarts and always display an active-filter chips row with hidden count, making filtering visible and reversible. * Graduate Agents view from experimental, refine activity handling - Agents Dashboard moves from experimental to standard feature with showAgentsSidebar setting controlling visibility - Add identity-checked cache eviction (dropPersisted IPC) to prevent newer runs from being evicted when UI clears older status, fixing clear-completed safety - Extract ActivityThreadHoverCardSummary and ActivityThreadListToolbar components for better organization and reusability - Implement mark-thread-read as separate action from select with clickable bell icon - Add hasActivityThreadWorkspace helper for checking workspace availability across hosts (SSH/runtime targets) - Preserve scope filter array identity during hydration for memo optimization - Track manually-unread turns in auto-ack to prevent re-acknowledgement - Clean up activity cleared-at cutoffs on pane retirement - Remove activity-thread-hover-card max-lines lint override (code refactored below threshold) * Refactor agent cache identity to use timing fields only - Simplify AgentStatusCacheIdentity: keep only paneKey, receivedAt, stateStartedAt - This fixes silent no-ops where renderer-enriched fields diverged from main's cache - Add worktree-jump-navigation for navigating activity to workspaces - Add manual mark-unread protection separate from auto-ack - Optimize activity owner resolution with per-build memoization - Optimize detected worktree lookup with indexed search * Remove sticky header, add scroll position persistence Replace the floating sticky header overlay with scroll position memory via a ref. This preserves the user's scroll location when switching between threads or remounting the agents list, improving UX without requiring React state. * Implement sticky group headers in activity thread list Keep group headers visible at the top while scrolling when threads are grouped. Headers stick to the viewport while their section is in view, then unstick as the next header approaches. * add blue flash * update settings appearnce * Extracted activity acknowledgement/clearance actions from the oversized UI slice. - Removed dead sidebar search/menu props and the unused search ref. - Removed the unnecessary sidebar visibility bitmask. - Replaced hardcoded sidebar toggle colors with design-system tokens. - Removed duplicate “mark all read / clear completed” controls in the sidebar. - Preserved manual-unread state correctly across pane retire, transfer, and drop. - Made clear-completed cutoffs monotonic so clock skew cannot resurrect old activity. - Fixed blank workspace names in hover cards with the existing fallback helper. - Added missing localization entries and stabilized hydrated filter array identity. - Updated misleading Agents setting copy to describe both sidebar surfaces. * add onboarding guide for the new agents panel * Add activity clearance tracking and synced agent view settings Agent view filters and presentation settings now sync across paired clients. Preserves per-pane activity clearance cutoffs in persistent state. Improves activity thread row accessibility with proper ARIA roles, and preserves terminal host ownership after pane teardown via retained terminal handle. * rm html * Graduate Agents from experimental and improve activity visibility - Migrate `showAgentsSidebar` setting from legacy experimental flags; default new profiles to the agents sidebar - Replace scoped-thread filtering with visible-thread filtering so bulk actions (mark all read, clear completed) only affect rendered rows - Rewrite child agent classification as a set of visible pane keys to fix orphan promotion and parent-cycle handling - Improve activity cleared-at cutoff lifecycle: preserve on row dismissal (pane may still be live) but clear on pane removal - Add pagehide flush for pending clear-completed evictions so quit/reload cannot replay cleared activity - Polish agents sidebar: unread count badge, expand button, onboarding intro for migrated/new users - Extract shared time-ago formatting to a library module - Fix scroll restoration to defer until content can contain the saved offset - Improve stable message hold for compact agent rows using state instead of refs - Add worktree filter-visibility check to distinguish collapsed-but-unfiltered from filtered-hidden * Graduate Agents from experimental and improve activity visibility - Remove the deprecated full-page Agents view; fix settings navigation fallback - Refactor bulk action bindings and separate mark-all-read from visible threads - Preserve sidebar collapse state across remounts; fix child-agent badge filtering - Add safety window for scroll-restore and improve worktree host-qualified filtering * Graduate Agents from experimental and add manual unread tracking - Move Agents sidebar from experimental settings to standard feature with intro flow - Add persistent manual unread turn tracking for activity feed - Consolidate workspace activation through activateAndRevealWorkspace dispatcher - Improve sidebar view toggle with radio semantics and arrow-key navigation * Graduate Agents sidebar and separate dashboard experiment The Agents tab now has its own `showAgentsSidebar` setting (defaults on) independent from the dashboard popout experiment. Activity unread counting is simplified to count all events uniformly without mode-specific filtering. Dashboard visibility is now controlled solely by `experimentalAgentDashboardPopout`, with its own UI in the Experimental settings pane. Migration path updated: only `experimentalActivity=true` graduates to the sidebar; the dashboard experiment remains separate. * Add agent-session tab support to activity tracking Build activity event contexts from structured agent-session tabs and worktree-attributed status entries. When activating a thread, try agent-session tab activation before falling back to terminal pane. * • The workspace sidebar tab is now a static Spaces label—no grouping-based “Projects” label or hidden width-reservation span. * Show unread count badge and prioritize attention-needing agent threads Activity group order now surfaces threads needing attention (blocked, waiting, interrupted) before working/done so they're never buried. The Agents tab shows an unread count badge while viewing Spaces, since the open Agents list already highlights unread rows. Also improves UX text ("Hide Agents" vs "Maybe later"), accessibility with proper ARIA labels, and handles edge cases: preserves read state for retained panes on SSH reconnect and handles deleted worktrees gracefully in navigation. * Batch agent-status evictions and optimize activity pane rebuilds - Add dropPersistedStatusEntries batch API; consolidate evictions into one persist - Implement fallback timeout in clear-completed for unseen toast callbacks - Project only activity-relevant tabs; memoize terminal tab derivations - Stabilize activity virtualizer key to prevent unnecessary item measurements * Remove unread count badge from Agents sidebar tab Simplify useActivityUnreadCount by removing the enabled parameter and conditional logic, as the badge is no longer displayed in the UI. * Deduplicate activity unread counts across source overlaps Live pane status is the primary source; retained and migration entries serve as fallback caches that may briefly overlap it during lifecycle transitions. Count each pane only once by tracking seen keys, prioritizing the live status as the canonical source. Also fix monitoring state display: it's a distinct agent state, not a tool-running row state, so exclude it from tool preview checks. * Update activity pane tests to remove unread badge assertions - Remove ActivityPaneVisibility type and readActivityPaneVisibility() helper - Update agentsSidebarButton selector to match badge-less state - Simplify assertions to check pane focus instead of visibility isolation - Remove test for unread badge acknowledgement flow * Fix activity pane workspace resolution and localization handling - Thread defaultHostId through activity operations for correct host resolution - Add language-aware caching for standalone terminal names with cache invalidation - Fix scroll restoration bounds calculation for tall viewports - Add focus management to sidebar radio group keyboard navigation - Refresh localized sidebar content on language changes - Preserve activity state across heartbeats to prevent history loss - Improve host-id strictness in worktree jump navigation * Preserve activity view when settings fetch fails A failed window.api.settings.get() leaves settings null, which was incorrectly treated as opt-out. Add the missing null check so the activity-view gate only applies when settings are available. Includes tests for this scenario and related edge cases in keyboard navigation, worktree jumping, and session state handling.Zustand re-runs every mounted subscriber's selector on every store write. The per-worktree sidebar selectors built a fresh Record per call, so 15 visible cards x 6 reads x every write allocated a record each time even when nothing they read had changed. - Add createWorktreeRecordSelector: gates the build on the source slice identities, memoizes per worktree id, and carries the previous generation forward so a rebuild with equal contents keeps its reference. - Route the pane-title, live-PTY, layout-root and terminal-layout selectors through it, and return a shared frozen empty when a worktree has no tabs. - Swap useWorktreeAgentRows' inactive-branch `[]`/`{}` literals for the shared frozen constants so the `active` gate actually short-circuits on identity. - Identity-cache the sidebar pending-worktree-creation key list, which ran Object.values(...).map(...) from an always-mounted subscriber. - Drop `key={text}` from TruncatedSidebarLabel so a label change remeasures in place instead of remounting the span and rebuilding its ResizeObserver. - Remove the non-compositable `width` from the board drop indicator's will-change hint.pscapture loudly, and stop a resume spending 49 of them (#18166) 6b1cbe54a1The POSIX process-table capture ran `execFile('ps', ...)` with no `maxBuffer`, inheriting Node's 1MB default. Measured at 1,460 processes the capture is 326KB with a 5,116-char longest row — ~3x headroom, which a busy host clears. Two separate defects follow, fixed here: 1. `parseProcessTableRows` drops unparseable lines, so any short capture reads as a COMPLETE table whose missing processes simply are not running. Verified: a capture cut at 4KB parses to 59 of 1,463 rows, and an empty capture parses to `[]`, both with no error — and `resolveAgentForegroundProcessWithAvailability` then answers `available: true`. That is the `unverifiable` -> `exited` collapse the execution boundary forbids. The capture now rejects with `ProcessTableCaptureError` on a ceiling-length or row-less capture, so both the lenient and strict views fail loudly and callers report unavailable. 2. `maxBuffer` is now an explicit 32MB, matching the sibling reader in `pty-descendant-termination.ts` and its stated reasoning. Without it a 4,000- process host fails EVERY capture, degrading the whole subsystem permanently. Separately, `readStructuredTuiProcessIdentity` polled a fresh whole-machine `ps` every 50ms for up to 5s. Each capture costs ~0.065 CPU-s, and the 5s ceiling is only reached when the child never appears — where the tight interval buys nothing. The interval now holds at 50ms for the first second, then doubles to a 500ms cap. Identification latency is unchanged for any child appearing inside that window, and the 5s ceiling is unchanged.* perf(renderer): stop six timers from ticking behind a hidden window IntensiveWakeUpThrottling is disabled in this app, so a renderer interval really does fire at full rate with the window hidden. Six of them had nothing to observe them: - NativeChatWorkingStatus ran a 1s interval + setState per in-flight turn purely to advance an elapsed-seconds counter. Deleted the effect and derived elapsed during render from the shared, visibility-gated useNow(1_000) clock, so N turns collapse onto one tick. - The chromium-error fallback poll (250ms) kept probing a stuck-loading guest to write a loadError nobody could see. - The contextual-tour full-pass interval (500ms) woke twice a second to queue a rAF a hidden window never paints. - Three feature-wall animation timers (3600/2400/2400ms) kept committing React renders for animations nobody was watching. - The landing preflight poll (30s) kept forcing IPC refreshes. All five gated timers reuse installWindowVisibilityInterval. Each either resumes where it left off (animations) or re-derives from durable state on the becoming-visible run, so hiding and re-showing is observationally identical to never hiding. * test(git): stop two empty commits in the divergence fixture from hashing alike `counts drift in both directions` builds 100 empty commits, resets to the fork point, then adds one more — expecting 100 ahead + 1 behind to clear the cap of 100. An empty commit's hash covers only parent, tree, message and a one-second-granularity timestamp, and every commit in the fixture reuses `commit ${index}` starting from 0. On a runner fast enough to finish the whole build inside one wall-clock second (CI: 1059ms for the case, ~7ms per commit), the post-reset `commit 0` hashed identically to the first `commit 0` of the chain, so Git handed back that same object and left the branch 99/0 apart instead of 100/1 — `within`, not `exceeded`. Numbering the empty commits across calls makes the fixture build the 101 distinct commits it already claimed to. Reproduced deterministically by pinning GIT_AUTHOR_DATE/GIT_COMMITTER_DATE, which forces the timestamp collision the fast runner hits by chance: fails with the exact CI assertion before, passes after.* fix(ssh): stop SFTP stream errors crashing main and bound the relay socket path inside the protocol parser. Every transfer removed its listener on settle, so a STATUS reply that arrived late - the normal case behind a jump host that chroots its SFTP subsystem - threw synchronously out of Socket.emit('data') and killed the main process. Keep one durable listener per stream, and report a sandboxed SFTP namespace with an actionable message instead of a bare 'file does not exist'. 104 macOS) and bind failed with a bare 'listen EINVAL'. Fall back to a per-uid base whose length does not depend on $HOME, keeping the hashed socket name intact. * fix(ssh): validate the short socket dir before mutating it * fix(ssh): keep the SFTP session guarded, scope the relocated socket, narrow the chroot verdict Three review findings. The CLI-launcher install ran writeStringViaSftp in a loop over a bare conn.sftp(). That helper removes its own session 'error' listener at each settle, so between files and after the last one the emitter carried none -- and ssh2 raises a late STATUS reply synchronously out of Protocol.parse, which is the uncaught exception that kills main (#15479). The inline loop it replaced leaked one listener per file and covered this by accident. Extract writeStringsViaSftp, which owns the session latch, and share that latch with runSftpFallbackTransfer. SSH_FX_PERMISSION_DENIED is a mode/ownership refusal on a path the subsystem can see, not evidence of a chroot; sftp-namespace-resolution already treats only NO_SUCH_FILE as conclusive. Narrow the predicate to code 2 so a read-only home stops being reported as a bastion misconfiguration. The relocated socket had no version dimension. relaySocketNameForInstanceId hashes the target, not the build, and under $HOME the enclosing relay-<fullVersion> dir supplied the rest -- so the short form made the path stable across updates. The next build would bind the path the previous relay still holds, the handshake would mismatch, and a relay holding live work would raise RelayEndpointHeldError with no way through. Add a hashed version segment under the short base, mirroring the relay-*/<sock> shape so one pattern serves both, and teach the superseded sweep and force-stop about that base. The relocated tree now also gets reclaimed: nothing else walks it. * fix(i18n): restore the activity-options key the rebase dropped * fix(i18n): union en.json with main so the rebase cannot drop keysThe GC pass that runs ~10s after every launch stat'd every entry in the terminal-history root just to test isDirectory(), then readdir'd each directory and stat'd every file inside it to accumulate `totalSizeKB` — a field whose only consumer was one `console.log`. The root listing now uses `readdir(root, { withFileTypes: true })` and reads `dirent.isDirectory()`, falling back to `stat` only for symlinks so a symlinked history directory keeps resolving through its target. The size estimation and `totalSizeKB` are gone, along with the log field. On a 50-dir x 3-file fixture: 301 readdir+stat calls -> 51. Extrapolated to the reported 2,781-dir / 6,697-file corpus: 13,906 -> 2,782.* perf(editor): stop reclassifying the whole markdown document on every render EditorPanel re-renders from ~18 store subscriptions, and the rich-mode classifier ran unmemoized in its render body — so an idle git-status poll rescanned (and TipTap round-tripped) every open markdown tab. - Memoize `getMarkdownRichModeEligibility` on (content, sizeOverridden). Idle 60 s with one markdown tab: 31 -> 1 classifier calls, 31 -> 1 round-trip calls. Render-model self time for a 100 KB .md with HTML: 6.485 ms -> 0.004 ms per render. - Drop the React effect that mirrored `content` into the doc-link decoration refresh; the controller's own `onDidChangeModelContent` listener already covers it (and catches programmatic edits). 800 -> 400 debounce timer ops per 200 keystrokes, same decorations. - Scan doc-link decorations by line offsets instead of per-line substrings, reusing the allocation-free `forEachLine` from the conflict decorations. 600 KB / 46k lines: 50,853 -> 4,623 string allocations per scan, 34.1 ms -> 20.3 ms per scan. - Replace the per-keystroke double `trimEnd()` dirty check with a trimmed-length probe plus one native prefix compare: 400 -> 0 full-document copies (40 MB -> 0 bytes) per 200 keystrokes. Move fileContents/diffContents behind refs so the change callback identity stops churning on every content load. No behavior change: rich-vs-source selection, decorations, and the dirty dot are all covered by equivalence tests against the previous code. * fix(editor): move the dirty-check content refs out of the render body React Doctor's `no-ref-current-in-render` flagged the `fileContents` / `diffContents` ref writes added for the stabilized change callback, and it was right on substance: a render React discards would still have moved the dirty-check baseline, so a later keystroke could be compared against content from a render that never committed. Assign both refs in a `useLayoutEffect` instead — the same latest-value pattern `useIpynbDocumentEditing` already uses. Layout effects only run for committed renders and land before any input event can reach the handler, so the baseline is always the committed one. The handler and its refs move into `use-editor-content-change-handler.ts`; that keeps `EditorPanel.tsx` under the 400-line cap (no `max-lines` bump) and puts the draft write, the dirty comparison and their inputs in one place. Callback identity stays stable (1 distinct identity across 30 idle renders) and every measured number is unchanged: 1 classifier call and 1 round-trip call per 60 s idle, ~0.005 ms render-model self time for a 100 KB .md with HTML. Also asserts the reverse direction of the reload case — the stable handler marks the file dirty when handed the pre-reload content. * fix(editor): keep the rich-mode fallback banner localized behind the memo The memo keyed on `(content, sizeOverridden)`, but the value it cached was not a pure function of those two: `unsupportedMessage` comes from a matcher `get message()` accessor that calls `translate()` at access time, so the active UI language is a third, ambient input. Caching the resolved string froze the banner in whichever language was active at first classification — visible when switching Settings → UI language, and at startup for non-English users because `I18nProvider` applies the persisted language from an effect, after the settings-driven render has already classified. Adding the language to the cache key would only work until the next ambient input. Instead, split the classifier: `getMarkdownRichModeEligibilityDecision` returns the genuinely pure part (`exceedsSizeLimit` plus which matcher fired) and is what the cache stores, while `resolveMarkdownRichModeUnsupportedMessage` reads the matcher's getter per read. `getMarkdownRichModeEligibility` keeps its old signature as a thin composition of the two. Cost of re-resolving per read is one `i18n.t` on documents that show a banner and nothing at all on documents that do not (a null reason short-circuits). Render-model self time for a 100 KB .md with HTML moves 0.005 ms -> 0.011 ms, against a 6.485 ms pre-PR baseline; classification still runs once per content change (1 decision and 1 round-trip across 30 idle git-status ticks). Two regression tests, both confirmed to fail against a string-caching cache: a unit test asserting a cache hit follows `changeLanguage('ja')`, and an EditorPanel test that renders the banner from a reference-link document, switches the language, forces an idle store write, and asserts the Japanese text while the decision stays cached.View command line instructions
Manual merge helper
Use this merge commit message when completing the merge manually.
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.