orca/docs/agent-status-over-wsl.md

25 KiB

Agent Status over WSL (STA-1515)

Status: implemented and rig-validated (2026-07-09 round 3 + 2026-07-10 round-4 re-run pinned to the hardened build, Windows 11 + WSL2 NAT): Claude end-to-end live — provisioning, working→done in the store, completion toast, loopback-only posture, and restart resume over a daemon-surviving PTY with the instance-keyed endpoint dir reused across restarts. The round-4 re-run also proved the --exec spawn form live (host process table) and the stale-exit reinstall upgrading the guest to the new bundle version in place. Residual: Codex's done/Stop leg unproven live — env-blocked on the rig (no dev-profile Codex credentials AND the model backend is unreachable from the guest under NAT); everything that fired behaved correctly. Confirm on a credentialed rig with a guest-reachable backend, ideally on a fresh distro to also observe the deferred Codex trust entries landing after config.toml is seeded. Owner: brennanb2025. Linear: STA-1515. Precedent this mirrors: the SSH agent-hook relay (src/relay/agent-hook-server.ts, src/shared/agent-hook-relay.ts, ingest at agentHookServer.ingestRemote in src/main/agent-hooks/server.ts).

Background — how we got here

GitHub issue 7565 reported OMP agents in WSL worktrees disappearing from the worktree sidebar after v1.4.124. Diagnosis split it into a regression and a pre-existing class gap:

  • The regression was a title-normalization change (PR 7447) that stopped idle OMP titles from producing the sidebar's title-derived fallback row. Decision: the sidebar is moving to hook-driven rows only (fallback removal in flight, separate PR), so the fallback was not restored.
  • The class gap is that agent hooks have never worked from inside WSL for any agent. Two scoped PRs fixed it for OMP alone (merged, live-validated on a Windows+WSL2-NAT rig):
    • PR 7642 — Orca-managed WSL shells wrap interactive omp invocations with --extension "$ORCA_OMP_STATUS_EXTENSION" (the env var is WSLENV /p-translated so the WSL process reads the extension out of the Windows filesystem via /mnt/c).
    • PR 7641 — when the extension's loopback POST cannot connect, it delivers via Windows-side /mnt/c/Windows/System32/curl.exe (a Windows process, so its 127.0.0.1 is the loopback Orca actually binds). Fire-and-forget spawn, --noproxy 127.0.0.1, memoized WSL/curl probes, load-tolerant timeouts (--connect-timeout 3 --max-time 10; 0.5s dropped events under load).

This document is the full context for the general fix: every other hook client is still dead from WSL, and the hooks-only sidebar change makes this work the gate for the Windows+WSL story.

Why hooks don't work on Windows+WSL — two independent gaps

Gap A — transport

The hook listener binds 127.0.0.1 only, deliberately (src/main/agent-hooks/server.ts, listen(0, '127.0.0.1'); auth via X-Orca-Agent-Hook-Token, 403 otherwise). Every hook client POSTs to a hardcoded http://127.0.0.1:$ORCA_AGENT_HOOK_PORT/hook/<source>.

WSL2 under default NAT networking is a VM with its own network namespace. Microsoft's localhost forwarding is one-way (Windows→WSL only): 127.0.0.1 inside WSL is WSL's own loopback, so every POST dies ECONNREFUSED — silently, because hook clients are deliberately fail-open. Reaching Windows from WSL would require the host vNIC IP (changes per boot) + a non-loopback listener bind + a firewall rule — all three conflict with the loopback-only security posture.

The env coordinates DO cross correctly (src/main/pty/wsl-orca-env.ts addOrcaWslInteropEnv: WSLENV PORT/u TOKEN/u ENV/u VERSION/u plus ORCA_AGENT_HOOK_ENDPOINT/p path-translated; called from src/main/ipc/pty.ts and src/main/daemon/pty-subprocess.ts). The address is simply unreachable.

Opt-in mirrored networking (Win11, .wslconfig) shares loopback and makes plain fetch work — the fix must not fight it. No wslinfo probing is needed: under mirrored mode the relay's preferred-port bind collides with the Windows listener and the EADDRINUSE fallback (below) handles it, while clients that go straight to the shared loopback reach the Windows listener directly. Both delivery paths stay valid.

Gap B — installation

Hook configs and scripts are written to the Windows home by every hook service: Claude settings.json + managed scripts, Codex config, Gemini/Cursor/Droid/Devin/Grok/ Copilot scripts, Amp/OpenCode plugin files, the Pi/OMP extension file. An agent inside WSL reads the WSL-side $HOME and sees none of it. There is zero WSL-targeted install code in src/main/agent-hooks/ or any hook service. SSH remotes have the exact precedent needed: dedicated remote installers (src/main/ssh/ssh-relay-session.ts remote settings.json handling; PR 7744 installed Droid/Copilot hooks over SSH).

Consequence: even mirrored-networking users get no hooks — transport fine, configs absent. OMP escapes Gap B by pointing across the boundary (/mnt/c path via /p translation) rather than installing WSL-side; that trick can carry file content for some clients, but shell hooks still execute inside WSL and then hit Gap A regardless.

Transport map — every client, how it posts

Endpoint file contract: writeEndpointFile (src/shared/agent-hook-listener.ts) emits exactly four keys (ORCA_AGENT_HOOK_PORT/TOKEN/ENV/VERSION) to endpoint.env (POSIX) / endpoint.cmd (Windows) — no host field. Shell clients source it to refresh stale coords after an Orca restart; node clients parse it. It is never executed as a delivery script. Clients prefer endpoint-FILE coords over env (restart re-coordination) — any transport change must preserve that property.

Client Mechanism Runtime that POSTs
Claude, Codex, Gemini, Cursor, Droid, Devin, Grok managed shell script curl (POSIX) / curl.exe (Windows), built in src/main/agent-hooks/installer-utils.ts
Copilot managed script curl (POSIX) / PowerShell Invoke-WebRequest (Windows)
command-code managed script (parse-not-source hardened) curl
Amp, OpenCode in-process node plugin fetch
Pi / OMP bundled in-process extension (src/main/pi/agent-status-extension-source.ts) fetch, now with the WSL curl.exe fallback

All of them target 127.0.0.1.

Status quo + why this gates the hooks-only sidebar

Today the worktree-card rows still have a title-derived fallback producer (src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts), so WSL users DO currently see rows for title-rich agents (Claude /spinner titles, Gemini glyphs) — degraded (generic text, no prompt/last-message preview, no notifications) but present. Title-poor agents are dark (Codex — hence GH 6907). When the hooks-only change removes that producer, every non-OMP agent in a WSL worktree loses its card row entirely until this work ships. Hook fidelity adds: prompt + last-assistant-message previews, waiting/blocked precision, completion notifications, AI Vault / native chat session integration.

Solution design — WSL relay + WSL-side installation

Transport (Gap A): guest-resident relay, host-owned stdio

Run a small receiver inside WSL on WSL's own loopback, listening on the very port the clients were already given ($ORCA_AGENT_HOOK_PORT — free inside WSL, since that port only exists on the Windows side). Unmodified clients then deliver successfully with zero client changes; the reporter's diagnostic relay in GH 7565 proved this shape live. Forward each parsed envelope to the Windows host over the relay's own stdio (Orca spawns it via wsl.exe, so it owns that pipe). Ingest through the existing trust boundary: agentHookServer.ingestRemote (src/main/agent-hooks/server.ts), envelope shape src/shared/agent-hook-relay.ts — identical to the SSH relay, which runs a loopback-only receiver on the remote box and forwards over the SSH control channel.

Port binding. Bind the inherited $ORCA_AGENT_HOOK_PORT first — it keeps every already-crossed coordinate (env and the /p-translated endpoint file) truthful with zero divergence. On EADDRINUSE inside the guest, fall back to the SSH relay's own pattern: bind 127.0.0.1:0, write a WSL-side endpoint file, and point WSL PTYs' ORCA_AGENT_HOOK_ENDPOINT at it (clients already prefer endpoint-file coords over env). The relay writes that WSL-side endpoint file in both modes so restart re-coordination never depends on /mnt/c translation being readable.

Lifecycle: one relay per distro per Orca instance (concurrent instances have distinct ports, so guest listeners never collide); ensure — not just start — whenever a WSL PTY exists: first spawn and daemon-PTY reattach after an Orca restart (WSL PTYs survive in the daemon; the new instance has a new port + token and must respawn the relay before surviving agents re-coordinate). The relay exits when its stdin closes: a lingering guest listener would let WSL's own Windows→WSL forwarder grab the freed Windows-side port and blackhole stale Windows-side hook posts. Restart if WSL restarts; token still validated at the relay's HTTP receiver; harmless under mirrored networking (bind fallback) and inert on non-WSL platforms (ensure only fires from WSL PTY spawns).

Reliability contract (invariant class agent-session.hook-transport): hook clients are fail-open silent, so the relay must not be — spawn failures, EADDRINUSE fallback, and forward errors each leave a diagnosable breadcrumb; the wsl.exe "Catastrophic failure (E_UNEXPECTED)" retry is bounded with backoff, never a spawn loop. Oracle: provider- contract tests with fault injection (stdin close → exit, occupied port → fallback + file rewrite, envelope round-trip to ingestRemote) rather than end-to-end flows only.

Design notes from a survey of comparable WSL-capable tools (kept nameless per policy):

  • Guest-resident component + host-owned channel + guest-side installation, explicitly reusing the tool's SSH-remote machinery, is the established pattern. No surveyed tool makes guest processes dial back to a Windows-localhost listener — the merged OMP curl.exe stopgap is the outlier as a primary path (but see the round-4 revised stance below: it survives as the no-node fallback).
  • Prefer host-owned stdio over Windows→WSL localhost port forwarding (wslhost forwarding is known-flaky under load; one surveyed tool dials the distro vNIC IP just to avoid it — stdio sidesteps the question entirely).
  • WSL offers no persistent control channel between separate wsl.exe invocations — collapse the relay's ensure-installed + launch into one idempotent script per spawn.
  • Install into the guest from inside the guest (download/extract in WSL) or stream the binary through wsl.exe stdin — not by copying through /mnt/c.

Installation (Gap B): WSL-side hook installers

Write agent hook configs/scripts into the WSL-side home, per agent, analogous to the SSH remote installers — via wsl.exe-executed scripts (preferred, mirrors SSH most closely) or \\wsl.localhost\<distro>\... writes. Without this half, the relay receives nothing: the hook clients themselves are absent from the WSL filesystem.

Alternatives considered (and why not)

  1. Endpoint-file host/URL field — the file already crosses into WSL (/p-translated), but clients read only PORT/TOKEN and hardcode the host, so all ~13 still need edits; and a WSL-reachable listener bind breaks the loopback-only posture (LAN exposure, firewall prompts).
  2. Replicate the curl.exe bridge per client — the shell clients share ~2 generated builders so it is cheaper than it sounds, but it is N point fixes, requires WSL interop enabled (/etc/wsl.conf can disable it), pays a per-event process spawn (load-sensitive, see validation facts), and keeps the ecosystem-outlier direction.
  3. Listener-side bind changes / rely on mirrored networking — posture conflict / opt-in only.
  4. OSC 9999 in-band status (src/shared/agent-status-osc.ts, parsed per-pane in pty-transport.ts and orca-runtime.ts) — zero-network and pane-attributed, but only viable for in-process clients and carries status payloads, not the full hook event vocabulary (prompts, tools, completion) — cannot replace the pipeline.

Facts + gotchas from the 2026-07-08 Windows-rig validation

  • curl.exe interop delivery works under NAT (shipped for OMP), but per-event process spawn is load-sensitive: --connect-timeout 0.5 dropped 3/3 events to a healthy listener under load; fine at 3s. A resident relay avoids per-event spawns entirely.
  • wslinfo --networking-mode distinguishes NAT vs mirrored.
  • Clients prefer endpoint-FILE coords over env. Testing gotcha: unset ORCA_AGENT_HOOK_ENDPOINT in synthetic tests or events go to the real running app.
  • Server ingest silently drops paneKeys that are not uuid:uuid-shaped — use real-shaped keys in synthetic validation.
  • OMP is a Bun single-file binary; Bun's node:child_process/fetch compat held. Other in-process clients run inside their agents' runtimes — verify per runtime.
  • Environmental: fresh WSL 2.7.10 intermittently threw "Catastrophic failure (E_UNEXPECTED)" from wsl.exe -d <distro> -- bash -lc under concurrent spawn load (cleared by wsl --terminate). The relay spawn path should tolerate/retry this.
  • Fork-PR CI runs sit in action_required until approved: gh api repos/stablyai/orca/actions/runs/<id>/approve -X POST.

Acceptance

On a default-config Windows 11 + WSL2 NAT machine: launch Codex or Claude (explicitly not OMP) in a WSL worktree → live hook-driven worktree-card row with status transitions and a completion notification; hook listener still bound to Windows loopback only; zero per-client transport changes; hooks installed WSL-side automatically (no manual config); harmless under mirrored networking and inert on non-WSL platforms. After an Orca restart with the WSL agent still running (daemon-surviving PTY), status events resume without relaunching the agent.

2026-07-09 Windows-rig validation follow-ups

The first live GUI run proved every mechanism in isolation but failed end-to-end, yielding two fixes:

  1. Link death must be handled, not just child death. A mux protocol error or timeout can kill the host↔guest link while the guest process (and its 204-returning receiver) stays alive — the exact observed signature: hooks POST 204, store never populates. wsl-hook-relay-link.ts now guarantees exactly-once death handling from either signal; the manager breadcrumbs it, kills the child, and self-restarts after a short cooldown (a live agent session produces no new PTY spawns to re-trigger ensure). ORCA_WSL_HOOK_RELAY_DEBUG=1 traces every received envelope pre-ingest so a live rig can pinpoint any residual drop. The full host chain is pinned by a live integration test (real bundle over real child stdio through the real manager into a real ingestRemote).
  2. (Round 2) The renderer's SSH-era ownership gate dropped wsl:* events. With the link fixed, envelopes reached ingestRemote and the durable cache, but useIpcEvents.applyAgentStatus compares the stamped connectionId against the owning repo's — "wsl:<distro>" !== null for a local repo, so every WSL-relayed status died before setAgentStatus/notifications. Fix: wsl:* ids are transport provenance, not ownership — the gate normalizes them to local (null) via isWslHookRelayConnectionId, while still rejecting WSL-stamped events against SSH-owned repos. Provenance stays stamped (it made the drop diagnosable in the first place).
  3. Codex reads a redirected home. Orca launches WSL Codex with CODEX_HOME pointed at the managed runtime home (~/.local/share/orca/codex-runtime-home/home), so installing hooks to ~/.codex left Codex dark. The installers now accept an explicit codex home; the trust write into config.toml is deferred while that file doesn't exist (the launch path seeds it only-if-absent — creating it first would cancel the seed), and the manager re-runs the idempotent installers on later ensures (throttled) to upsert trust once the seed lands. Consequence: the very first WSL Codex session after a cold relay may miss hooks; the next one has them.

2026-07-09 adversarial-review hardening (pre-rig round 3)

Four independent review lenses over the full diff; confirmed findings fixed:

  • Endpoint identity (all 4 reviewers): the guest endpoint dir was keyed by the ephemeral Windows hook port, so a daemon-surviving agent kept sourcing the DEAD port-P1 file after an Orca restart — breaking the restart-resume acceptance criterion and regressing shipped OMP recovery. Now keyed by a restart-stable instance key (hash of the Windows endpoint file path = userData + namespace, crossed via ORCA_WSL_HOOK_INSTANCE): the restarted instance's relay REWRITES the same file, which is exactly what re-coordinates survivors.
  • Restart policy: every failure now arms the restart timer (one failed relaunch no longer ends self-recovery), and the timer probes wsl --list --running first — wsl -d BOOTS a stopped distro, so recovery must never resurrect a VM the user shut down; a stopped distro's state is dropped instead (next WSL terminal re-ensures). Failure counters only reset after 2 min of stable uptime, so connect-then-die loops escalate to the 10-min cap instead of cycling every 10s.
  • Install-dir versioning: the guest install dir is namespaced by bundle version, so concurrent Orca instances with different bundles (dev + prod) never reinstall over each other; tmp files carry the guest PID. The install spawn also gained the 30s timeout it was missing (a wedged wsl.exe could previously pin the state machine at 'starting' forever).
  • Guest node resolution: candidates (PATH, nvm glob, fixed paths) are each version-probed, first pass wins — an apt node 12 on PATH no longer masks an nvm node 20 into a false "no node >= 18" 10-minute cooldown.
  • wsl.exe text handling: WSL_UTF8=1 on all spawns + NUL-stripping on stderr, so the "Catastrophic failure" transient-retry matcher and breadcrumbs survive UTF-16LE output.
  • Smaller: ordered post-sentinel chunk handoff (frame-decoder desync race), port-fallback breadcrumb now reaches host logs via the home handshake, bad home reply fails the connect (was: silently 'running' without installs), missing-bundle warn-once, distro map keys case-normalized, disposeAll wired to app will-quit, single-spawn Codex trust catch-up via a one-shot 60s reinstall timer.

Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced install dirs accrete across upgrades (~200KB each); an outdated running daemon /p-translates the guest endpoint path until it restarts (hook scripts fall back to env coords, which same-port binding keeps correct); wslDistroCache caches a transient empty list for the app run (pre-existing semantics, now load-bearing for default-distro resolution); default-distro resolution caches the first answer for the app run.

2026-07-09 round-4 external adversarial review

A second adversarial sweep (five independent lenses: guest relay + fs bridge, host lifecycle state machine, app integration + renderer gate, design-vs-alternatives, and a platform fact-check of every WSL claim). Design verdict: the guest-resident relay over host-owned stdio is the right architecture — the zero-per-client-change chokepoint is what the curl.exe alternative cannot match, and the lifecycle weight is inherent to any guest-resident helper. Confirmed findings, all fixed on this branch:

  • dropState identity race (major): the recovery timer re-checked state identity only BEFORE the async wsl --list --running probe; an ensure() landing during the probe could get its fresh state deleted by key — orphaning a live relay child outside the map (unkillable by disposeAll, duplicate relay on next ensure). Fixed: identity re-check after the probe await + identity-guarded delete in the manager.
  • Distro-running probe failed OPEN: any probe error (including its 10s timeout) reported "running", so recovery could wsl -d — and thereby BOOT — a distro the user shut down, in exactly the wedged-wsl.exe failure mode where the probe errors. Now fails closed: drop the state; the next WSL PTY spawn re-ensures.
  • Spawn form hardened to --exec: wsl.exe -- <cmd> routes through the distro's default login shell (Microsoft docs: only --exec runs "without using the default Linux shell"), so a fish/nushell chsh could mangle the launch; --exec sh -c/-s bypasses it and passes argv verbatim (no $-preprocessing, escaping shim dropped) — same form as the Codex WSL login spawn.
  • Post-sentinel handoff microtask: pending chunks flushed synchronously inside the mux constructor, before the manager could register notification handlers — an envelope arriving in the trailing bytes dispatched to zero handlers (recovered only by the later replay request). Flush now rides a microtask: after the caller's synchronous wiring, still ahead of any subsequent stdout IO event.
  • Relay process posture: the guest relay now mirrors the SSH relay's uncaughtException (log + exit → manager respawns) / unhandledRejection (log + survive) handlers.
  • Replay cache recency cap: the WSL relay has no per-pane teardown signal, so the per-pane replay cache grew for the relay's lifetime; now capped at 256 panes, evicting longest-idle first (meta map kept in lockstep). Backstop for SSH too.
  • Smaller: guest launch script derives the stale-exit code from the shared contract constant (was a hardcoded 42 twin); one-shot reinstall timer refuses to arm after dispose; fs-bridge scope comment states the lexical (symlink-following) bound honestly. New oracles: sentinel unit suite (chunk splits, overflow kill, timeout, microtask handoff), fs-bridge scoping suite, 403 + fallback endpoint-file rewrite, cache-cap eviction, and the recovery/manager race regressions.

Revised stance on the OMP curl.exe bridge — keep it, do not retire. The relay requires node ≥ 18 in the distro; a fresh WSL Ubuntu ships none, Codex CLI is a native binary that brings none, and Claude Code's native installer no longer implies a system node. A distro running only Codex would hit the no-node cooldown and stay dark — the exact GH 6907 shape. The interop bridge is the one delivery path with no guest runtime requirement, so it stays as the documented no-node fallback (currently wired for OMP; extending it to the shared shell-script builders is the tracked follow-up if no-node distros show up in telemetry). The relay remains the primary path: resident (no per-event spawn cost) and interop-independent.

Implementation map

  • Guest: src/relay/wsl-agent-hook-relay.ts (entry; exits on stdin close), src/relay/wsl-hook-fs-bridge.ts (home-scoped fs RPCs for installs), src/relay/agent-hook-server.ts (token/preferredPort options + EADDRINUSE fallback). Bundled by config/scripts/build-relay.mjsout/relay/wsl/.
  • Host: src/main/agent-hooks/wsl-hook-relay-manager.ts (per-distro state machine), wsl-hook-relay-launch.ts (bundle resolve, guest launch/install scripts, spawn env, sentinel wait), wsl-hook-relay-link.ts (envelope forward + exactly-once link-death handling), wsl-hook-relay-deps.ts (DI seam), wsl-hook-fs-adapter.ts (SFTP-shaped adapter + installWslGuestHooks, which targets Codex's managed runtime home).
  • Wiring: buildPtyHostEnv (src/main/ipc/pty.ts) ensures the relay on every WSL spawn and repoints ORCA_AGENT_HOOK_ENDPOINT at the guest endpoint file once known; src/main/pty/wsl-orca-env.ts picks /u vs /p by value shape.
  • Contract shared by both sides: src/shared/wsl-hook-relay-contract.ts.
  • Oracles: src/relay/wsl-agent-hook-relay.test.ts, src/main/agent-hooks/wsl-hook-relay-manager.test.ts (fault injection: stale-42 reinstall, no-node-43 cooldown, bounded E_UNEXPECTED retry, exit re-ensure gating, and a full installer run against an in-memory guest).

References

  • GitHub: issues 6907 (Codex/WSL), 7091 + 7565 (OMP, fixed), 7563 (WSL CLI detection, adjacent); PRs 7642 + 7641 (OMP fixes), 7744 (SSH hook installers precedent), 7447 (title-collapse regression).
  • Linear: STA-1515 (this work; ticket comments carry the same context).
  • Key files: src/main/agent-hooks/server.ts, src/shared/agent-hook-listener.ts, src/shared/agent-hook-relay.ts, src/relay/agent-hook-server.ts, src/relay/relay.ts, src/main/pty/wsl-orca-env.ts, src/main/agent-hooks/installer-utils.ts, src/main/pi/agent-status-extension-source.ts, src/main/ssh/ssh-relay-session.ts, src/main/providers/windows-shell-args.ts, src/shared/wsl-login-shell-command.ts.