Merge remote-tracking branch 'origin/main' into Jinwoo-H/manual-worktree-delete

This commit is contained in:
Jinwoo Hong
2026-07-10 05:28:09 -04:00
164 changed files with 8878 additions and 1183 deletions
+3
View File
@@ -60,6 +60,9 @@ pnpm-debug.log*
# Coverage
coverage/
# Prod release scan output (accidental adds)
prod-release-scan-*.md
# Benchmark run output
/tools/benchmarks/results/*.json
/.bench-fixtures/
+27
View File
@@ -59,4 +59,31 @@ for (const platform of PLATFORMS) {
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
}
// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL
// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher),
// so a single platform-independent bundle suffices; it ships inside the
// Windows app via the same out/relay extraResources mapping.
{
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const outDir = join(ROOT, 'out', 'relay', 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslEntry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-agent-hook-relay.js'),
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js'))
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
}
console.log('Relay build complete.')
+6 -4
View File
@@ -279,10 +279,12 @@ function getPatchedNodePtyRebuildReason() {
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
// patch only lands in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const missingArtifact = [
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')
].find((artifactPath) => !existsSync(artifactPath))
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
return null
+40 -3
View File
@@ -117,6 +117,40 @@ describe('ensure-native-runtime', () => {
}
}
)
it.skipIf(process.platform === 'win32')(
'keeps the fast path when the platform-specific patched artifacts exist',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
})
function mkTempProject() {
@@ -161,7 +195,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
)
}
function writeLoadableNativeModules(projectDir) {
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
@@ -173,7 +207,8 @@ const { appendFileSync, existsSync } = require('node:fs')
exports.loadNativeModule = function loadNativeModule(nativeName) {
const rebuilt = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
const dir = rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/'
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
return { dir, module: {} }
}
@@ -190,7 +225,9 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
writeFileSync(join(buildDir, 'pty.node'), '')
writeFileSync(join(buildDir, 'spawn-helper'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
}
}
function writeFakePnpm(binDir) {
+6 -4
View File
@@ -349,10 +349,12 @@ function getPatchedNodePtyRebuildReason() {
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
// load successfully in Electron while missing the patched fd/error handling.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const missingArtifact = [
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')
].find((artifactPath) => !existsSync(artifactPath))
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
return null
+3 -1
View File
@@ -412,5 +412,7 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
writeFileSync(join(buildDir, 'pty.node'), '')
writeFileSync(join(buildDir, 'spawn-helper'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
}
}
+374
View File
@@ -0,0 +1,374 @@
# 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.mjs` → `out/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`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.132-rc.0",
"version": "1.4.133-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
-152
View File
@@ -1,152 +0,0 @@
# Prod Release Scan — output.md
**Verdict:** ready with accepted P1 risk
**Span:** `v1.4.130` (`31378023`) .. `v1.4.131-rc.2` (`6964bfb23`)
**Planned release / RC:** `1.4.131-rc.2` (user asked for 1.4.132-rc2; that tag does not exist — scanned confirmed substitute)
**Included PRs:** 8 matched (+ `#7567` referenced only in `#7658` title, not a separate PR); unmatched commits: 5
**Batches reviewed:** 6 (agent/mobile, codex/speech/WSL, UI defaults, xplat/remoting, perf, security)
**Security model:** `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"` (W6 + all workers)
**Orchestration:** 6 same-worktree tasks injected; `task-list`/`dispatch-show` verified
---
## Issue table by PR / commit
### Has issues
| Ref | Title | Severity | Issue summary |
|-----|-------|----------|---------------|
| **#7906** | Wake slept agents when opening a worktree on mobile | **P1** (×3) | (1) Non-passive / manual-sleep wake on an **unmounted** worktree creates a tab with `activate:false` but never background-mounts → **no PTY / resume lost**. (2) Mounted manual-sleep can **double-resume** the same provider session (in-place wake + generic resume). (3) Edge-triggered wake can be **lost** if mobile opens during hibernation kill before `hibernatedWakePtyId` is armed. |
| **#7906** | (perf composition) | **P1** | Passive completed wake background-mounts the **entire** worktree → O(all saved tabs) PTY/xterm work, defeating unvisited-worktree mass-spawn guard. |
| **#7906** | (headless remoting) | **P1** | Wake is a no-op without a live renderer window; headless `orca serve --mobile-pairing` activates without waking slept agents. |
| **#7960** | Write in-Codex setting changes back to `~/.codex` | **P1** (×2–3) | (1) Missing `~/.codex/` parent → `ENOENT` on promote, then mirror **wipes** runtime setting (test pre-creates dir and misses this). (2) `writeFileAtomically` without `mode` widens **0600 → 0644** on promote (reproduced). (3) Host-only promotion path: **WSL Codex never promotes** (WSL prep skips `syncSystemConfigIntoManagedCodexHome`). |
| **#7658** | Recover WSL Codex usage RPC refresh | **P2** | Every WSL quota poll uses interactive login shell (`-ilc` + discovery chain); heavy rc cost on 15m poll / double on PTY fallback. |
| **#7977** | Disable experimental new worktree card style by default | **P2** | Fresh default is correctly off, but cohort **auto-enabled in v1.4.130** stays on via persisted `true` (no rollback migration). |
| **#7845** | Sidebar scrollbar only when overflow | **P2** (docs only) | Behavior OK; `docs/STYLEGUIDE.md` still says gutter is reserved — stale contract. |
### No issues found (clean for this scan)
| Ref | Title | Notes |
|-----|-------|-------|
| **#7860** | Separate pane identity from liveness (OMP tab flicker) | Identity vs liveness separation consistent across tab icon, PTY owner, web mirror; focused-pane owner re-owns Pi-compatible titles; no proven regression. |
| **#7932** | Chunk offline audio decoding (ONNX SIGTRAP) | 30s chunk bound + quiet split; worker-side decode; pure chunker tests pass; no malware/native dep delta. Residual: no packaged ONNX runtime on all platforms. |
| **#7933** | iOS native keyboard dictation in mobile terminal | Raw field echo; normalize only on send/mirror; no double-send proven. Mobile vitest not run (missing Expo deps in this worktree). |
| `775fa956c` | Remove mobile driver overlay preview | Delete-only HTML mock. |
| `8adfef4ff` | Update README downloads badge | `4.0m` → `4.1m` only. |
| `ff661a7ab` / `41edc493b` / `6964bfb23` | release: v1.4.131-rc.{0,1,2} | Version bumps in `package.json` only. |
---
## Findings first
### P0 — Release Blocking
None.
No malware, backdoor, supply-chain implant, lockfile/dependency implant, install-hook change, or updater/signing bypass in this span.
### P1 — Releasable Only With Explicit Acceptance
1. **#7906 Mobile wake — unmounted non-passive sleep never gets a PTY**
`wake-sleeping-agents-in-background.ts:43-56` only background-mounts for passive completed records; non-passive path creates inactive tab and clears the sleep record without a mounted `TerminalPane`.
*Evidence:* `wake-sleeping-agents-in-background.ts`, `resume-sleeping-agent-session.ts:93-118`, `Terminal.tsx:729-778`.
2. **#7906 Mobile wake — double resume of same provider session**
Broadcast in-place wake then generic resume for non-passive mounted records before spawn clears the record.
*Evidence:* `wake-sleeping-agents-in-background.ts:37-56`, `pty-connection.ts:6247-6255`, `resume-sleeping-agent-session.ts:279-315`.
3. **#7906 Mobile wake — race during hibernation kill**
Wake before `hibernatedWakePtyId` is armed is edge-triggered and not retried; passive path keeps record without launching.
*Evidence:* `pty-connection.ts:2090-2169`, `orca-runtime.ts` fire-and-forget notifier.
4. **#7906 Perf — whole-worktree background mount**
One passive sleep record mounts every terminal tab in the worktree permanently.
*Evidence:* `wake-sleeping-agents-in-background.ts:46-54`, `background-terminal-worktree-visibility.ts`, `Terminal.tsx` mount model.
5. **#7906 Headless mobile pairing — wake no-op**
No renderer ⇒ no wake; headless serve still returns activated.
*Evidence:* `orca-runtime.ts:12627-12633`, `orca-runtime.test.ts` “deliberate non-goal”, `mobile/scripts/start-emulator-pairing-runtime.mjs`.
6. **#7960 Missing `~/.codex` directory loses first promoted setting**
Promote does not `mkdir` system home; `ENOENT` swallowed; mirror then clears runtime value. Test pre-creates `.codex` and misses the case.
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:6-14`, `config-settings-promotion.test.ts:190-191`. Reproduced: missing parent → `ENOENT`.
7. **#7960 Permission widening on promote (privacy)**
Atomic write without mode: `0600` → `0644` under umask `0022` (reproduced). Full user config becomes world-readable to co-tenants when parent dirs are traversable.
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:11-14`.
8. **#7960 WSL never gets promotion**
WSL launch/rate-limit prep returns after `syncWslRuntimeForCurrentSelection` and never calls host mirror/promotion. Feature is host-only.
*Evidence:* `runtime-home-service.ts:139-150,225-235`.
### P2 — Nice To Have
- **#7658** Interactive login shell on every WSL rate-limit poll (`buildWslLoginShellCommand` / `-ilc`).
- **#7960** Unchanged baseline still rewritten twice per quota cycle; CRLF insert can mix EOL on new keys.
- **#7977** Auto-enabled experimental card cohort not rolled back.
- **#7845** STYLEGUIDE scrollbar gutter text out of date.
---
## Clean matrix
| Area | Status |
|------|--------|
| Security / malware / supply-chain | **Clean of malice.** Lockfile unchanged; package scripts unchanged; version-only package.json. **P1 residual:** mode widen on `~/.codex/config.toml` promote. |
| Performance / resource use | **P1** mobile whole-worktree mount; **P2** baseline double-write + WSL interactive poll cost. STT chunking is an improvement. |
| macOS / Linux / Windows / WSL / PowerShell | **P1** WSL promotion gap; **P2** WSL interactive poll; CRLF insert. No new hard-coded Mac shortcuts. Real Win/WSL runtime not executed. |
| Mobile / paired web / Electron | **P1** mobile wake correctness/composition gaps; #7860 / #7933 clean by source audit. |
| Local / SSH / remote / VM / container | Wake path failures apply to local and SSH worktrees once host renderer is involved. Headless serve gap explicit. |
| Git providers / shared contracts | Not materially touched. |
| Release / package / update path | Version bumps only; no workflow/signing/native rebuild changes. |
---
## Verification
| Check | Result |
|-------|--------|
| `git diff --check v1.4.130..v1.4.131-rc.2` | Passed |
| Required security grep on non-lockfile span | 2 hits — both TOML `.exec` parsers in #7960; inspected, benign |
| `package.json` / lockfile | Version field only; `pnpm-lock.yaml` no diff |
| Mode repro (`writeFileSync` without mode) | `before 600` → `after 644` |
| Missing parent repro | `ENOENT` |
| Worker unit tests (various focused suites) | Reported green by workers (promotion, STT chunker, rate-limit, wake helpers, pane owner, etc.) |
| Mobile Expo vitest / device / real WSL / ONNX packaged | **Not run** in this environment |
---
## Orchestration provenance
| Worker | Task | Terminal | Report |
|--------|------|----------|--------|
| W1 agent/mobile | `task_20d8fd2460cc` | `term_40e66a33-…` | `reports/w1-agent-mobile.md` |
| W2 codex/speech/WSL | `task_6e280ce1760e` | `term_59f3ead8-…` | `reports/w2-codex-speech-wsl.md` |
| W3 UI/defaults | `task_7140cb2014c9` | `term_4e274a95-…` | `reports/w3-ui-defaults.md` |
| W4 xplat/remoting | `task_424816ffe823` | `term_03c7aac8-…` | `reports/w4-xplat-remoting.md` |
| W5 perf | `task_1c5ac42d7659` | `term_24778551-…` | `reports/w5-perf.md` |
| W6 security | `task_bf5de6f4913a` | `term_ae19a68d-…` | `reports/w6-security.md` |
All workers: `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"`, same worktree, `--inject` dispatch.
---
## Residual risk
- No runtime on physical iOS/Android, Windows+WSL2, or packaged sherpa/ONNX for all platforms.
- Lockfile/binary deltas fully inspected: **none**. Install hooks unchanged.
- #7906 integration of IPC wake × mounted/unmounted panes × async PTY exit is under-tested relative to risk.
- #7960 Windows ACL inheritance and concurrent external `~/.codex` edits not runtime-tested; POSIX mode widen is proven.
---
## Bottom line
**No P0.** Multiple **P1**s cluster on **#7906 (mobile wake)** and **#7960 (Codex settings promotion)**. Ship only if product explicitly accepts those gaps, or fix before stable:
1. Always background-mount (or otherwise attach) when creating a navigation-suppressed resume tab.
2. Deduplicate in-place hibernation wake vs generic resume for the same claim key.
3. `mkdir` system `~/.codex` before promote; pass `mode: 0o600` (or preserve existing mode) into `writeFileAtomically`.
4. Decide WSL + headless pairing product stance (implement promotion/wake or document as unsupported).
Clean shippable areas for this span: **#7860**, **#7932**, **#7933**, release version commits, README badge, preview delete; **#7845** functionally clean with docs P2.
@@ -373,6 +373,40 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(onRenamed).not.toHaveBeenCalled()
})
it('surfaces a failed upstream probe and retries instead of settling silently (issue #7808)', async () => {
// Localized git (gettext + de_DE) translates even the `fatal:` prefix, so
// the no-upstream error is unrecognizable. This must not settle as "has
// upstream" — the badge goes up and a later event may still succeed.
const localizedError = new Error(
'Command failed: git rev-parse --abbrev-ref HEAD@{u}\n' +
"Schwerwiegend: Kein Upstream-Branch für Branch 'you/Nautilus' konfiguriert."
)
const plainResponder = gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
throw localizedError
}
return plainResponder(args)
})
const { deps, onRenamed, setRenameError } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(generateBranchNameMock).not.toHaveBeenCalled()
expect(onRenamed).not.toHaveBeenCalled()
expect(setRenameError).toHaveBeenCalledWith(
WORKTREE_ID,
expect.stringContaining('Schwerwiegend')
)
// Once git output is readable again, the same worktree retries and renames.
gitExecFileAsyncMock.mockImplementation(plainResponder)
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['branch', '-m', 'you/fix-auth'],
expect.objectContaining({ cwd: '/repo/wt' })
)
expect(setRenameError).toHaveBeenLastCalledWith(WORKTREE_ID, null)
})
it('leaves ineligible branches untouched even when their leaf is a creature name', async () => {
const { deps, onRenamed } = makeDeps({ canRenameOrcaCreatedBranch: () => false })
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
@@ -18,7 +18,7 @@ import { gitExecFileAsync } from '../git/runner'
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
branchHasUpstream,
probeBranchUpstream,
renameCurrentBranch,
resolveUniqueBranchName,
type GitExec
@@ -207,9 +207,20 @@ async function runAutoRename(
return stop(`branch "${currentBranch}" is not an auto-generated name`, true)
}
// Never rewrite a branch that's already been published.
if (await branchHasUpstream(exec)) {
const upstreamProbe = await probeBranchUpstream(exec)
if (upstreamProbe.outcome === 'has-upstream') {
return stop(`branch "${currentBranch}" already has an upstream`, true)
}
if (upstreamProbe.outcome === 'probe-failed') {
// Why: settling an unreadable probe as "has upstream" made this failure
// silent and permanent via the settled cache (issue #7808).
const probeError = upstreamProbe.message.replace(/\s+/g, ' ').trim()
deps.setRenameError(
worktreeId,
`Could not check whether the branch is published: ${probeError}`
)
return retry(`upstream probe failed: ${probeError}`)
}
const settings = deps.getSettings()
const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null)
@@ -249,8 +260,13 @@ async function runAutoRename(
if (branchNow !== currentBranch) {
return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`)
}
if (await branchHasUpstream(exec)) {
return retry(`branch "${currentBranch}" was pushed during generation`)
const postGenerationProbe = await probeBranchUpstream(exec)
if (postGenerationProbe.outcome !== 'no-upstream') {
return retry(
postGenerationProbe.outcome === 'has-upstream'
? `branch "${currentBranch}" was pushed during generation`
: `upstream probe failed after generation: ${postGenerationProbe.message}`
)
}
const username = provider
@@ -253,6 +253,48 @@ describe('remote hook service installers', () => {
expect(toml).toContain('trusted_hash = "sha256:')
})
it('installs Codex hooks into an explicit redirected CODEX_HOME (WSL managed runtime home)', async () => {
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
const { sftp, fs } = createFakeSftp({
[`${runtimeHome}/config.toml`]: 'model = "gpt-5.2-codex"\n'
})
const status = await new CodexHookService().installRemote(sftp, '/home/dev', {
codexHomeDir: runtimeHome,
deferTrustUntilConfigToml: true
})
expect(status.state).toBe('installed')
expect(status.configPath).toBe(`${runtimeHome}/hooks.json`)
expect(fs.files.has('/home/dev/.codex/hooks.json')).toBe(false)
const hooks = JSON.parse(fs.files.get(`${runtimeHome}/hooks.json`)!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
'/home/dev/.orca/agent-hooks/codex-hook.sh'
)
const toml = fs.files.get(`${runtimeHome}/config.toml`)
expect(toml).toContain('model = "gpt-5.2-codex"')
expect(toml).toContain(`${runtimeHome}/hooks.json:stop:0:0`)
})
it('defers Codex trust writes until the redirected config.toml exists (launch-path seed race)', async () => {
const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home'
const { sftp, fs } = createFakeSftp()
const status = await new CodexHookService().installRemote(sftp, '/home/dev', {
codexHomeDir: runtimeHome,
deferTrustUntilConfigToml: true
})
expect(status.state).toBe('installed')
expect(status.detail).toContain('deferred')
expect(fs.files.get(`${runtimeHome}/hooks.json`)).toContain('codex-hook.sh')
// Why: creating config.toml here would make the launch path's
// only-if-absent seed skip the user's real config.
expect(fs.files.has(`${runtimeHome}/config.toml`)).toBe(false)
})
it('reports Codex trust-write failures without rolling back installed hooks', async () => {
const { sftp, fs } = createFakeSftp()
fs.failRenameTo.add('/home/dev/.codex/config.toml')
@@ -15,15 +15,37 @@ import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type RemoteManagedHookInstallOptions = {
/** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime
* home). Codex-only: it is the one agent whose home Orca redirects. Also
* defers the config.toml trust write until that file exists, so the
* launch path's only-if-absent seed is never pre-empted. */
codexHomeDir?: string
}
type RemoteManagedHookInstaller = readonly [
AgentHookInstallStatus['agent'],
(sftp: SFTPWrapper, remoteHome: string) => Promise<AgentHookInstallStatus>
(
sftp: SFTPWrapper,
remoteHome: string,
options?: RemoteManagedHookInstallOptions
) => Promise<AgentHookInstallStatus>
]
const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)],
['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)],
['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)],
[
'codex',
(sftp, remoteHome, options) =>
codexHookService.installRemote(
sftp,
remoteHome,
options?.codexHomeDir
? { codexHomeDir: options.codexHomeDir, deferTrustUntilConfigToml: true }
: undefined
)
],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)],
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
@@ -45,12 +67,13 @@ export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStat
export async function installRemoteManagedAgentHooks(
sftp: SFTPWrapper,
remoteHome: string
remoteHome: string,
options?: RemoteManagedHookInstallOptions
): Promise<AgentHookInstallStatus[]> {
const results: AgentHookInstallStatus[] = []
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
try {
const result = await install(sftp, remoteHome)
const result = await install(sftp, remoteHome, options)
results.push(result)
if (result.state === 'error') {
console.warn(
+136
View File
@@ -0,0 +1,136 @@
// SFTP-shaped adapter over the WSL hook relay's fs bridge. Lets the
// unchanged SSH remote hook installers (`installRemoteManagedAgentHooks`)
// write into a WSL distro's home over the relay's already-open stdio channel
// — the WSL twin of the SSH flow's real SFTPWrapper. Only the primitives
// `installer-utils-remote.ts` touches are implemented.
import type { SFTPWrapper } from 'ssh2'
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
/** Run the shared remote hook installers against a WSL guest over the relay's
* fs bridge. Codex is the one agent whose home Orca redirects for WSL
* sessions, so its hooks go to the managed runtime home. */
export async function installWslGuestHooks(options: {
mux: SshChannelMultiplexer
guestHome: string
distro: string
installHooks: typeof installRemoteManagedAgentHooks
warn: (message: string) => void
}): Promise<void> {
const { mux, guestHome, distro, installHooks, warn } = options
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome)
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
warn(
`[agent-hooks] WSL hook install for '${distro}': ${failed}/${results.length} agents failed`
)
}
}
type SftpCallback<T = void> = (err: Error | null, value?: T) => void
// Why: installer-utils-remote classifies errors by ssh2's numeric SFTP status
// codes (ENOENT=2, already-exists=4). Map guest POSIX errno onto those so the
// shared classifiers keep working across transports.
const ERRNO_TO_SFTP_CODE: Record<string, number> = {
ENOENT: 2,
ENOTDIR: 2,
EACCES: 3,
EEXIST: 4
}
function toSftpError(failure: { errno?: string; message?: string }): Error {
const err = new Error(failure.message ?? 'wsl fs bridge failure') as Error & { code?: number }
err.code = ERRNO_TO_SFTP_CODE[failure.errno ?? ''] ?? 5
return err
}
export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrapper {
const call = <Wire extends object, Value>(
method: string,
params: Record<string, unknown>,
callback: SftpCallback<Value>,
pick: (result: { ok: true } & Wire) => Value
): void => {
mux
.request(method, params)
.then((raw) => {
const result = raw as WslFsResult<Wire>
if (!result || typeof result !== 'object' || result.ok !== true) {
callback(toSftpError((result ?? {}) as { errno?: string; message?: string }))
return
}
callback(null, pick(result))
})
.catch((err) => callback(err instanceof Error ? err : new Error(String(err))))
}
const callVoid = (
method: string,
params: Record<string, unknown>,
callback: SftpCallback
): void => {
call<Record<string, never>, undefined>(method, params, callback, () => undefined)
}
const adapter = {
readFile(path: string, _encoding: unknown, callback: SftpCallback<string>): void {
call<{ content: string }, string>(
WSL_HOOK_FS_METHODS.readFile,
{ path },
callback,
(r) => r.content
)
},
writeFile(
path: string,
content: string,
options: { mode?: number },
callback: SftpCallback
): void {
callVoid(WSL_HOOK_FS_METHODS.writeFile, { path, content, mode: options?.mode }, callback)
},
stat(path: string, callback: SftpCallback<{ mode: number }>): void {
call<{ mode: number }, { mode: number }>(
WSL_HOOK_FS_METHODS.stat,
{ path },
callback,
(r) => ({
mode: r.mode
})
)
},
// Why: POSIX rename overwrites atomically, which is exactly the OpenSSH
// overwrite-rename semantics the installers prefer — so the extension is
// "supported" here and plain rename shares the implementation.
ext_openssh_rename(src: string, dst: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.rename, { src, dst }, callback)
},
rename(src: string, dst: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.rename, { src, dst }, callback)
},
unlink(path: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.unlink, { path }, callback)
},
chmod(path: string, mode: number, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.chmod, { path, mode }, callback)
},
readdir(path: string, callback: SftpCallback<{ filename: string }[]>): void {
call<{ entries: { filename: string }[] }, { filename: string }[]>(
WSL_HOOK_FS_METHODS.readdir,
{ path },
callback,
(r) => r.entries
)
},
mkdir(path: string, callback: SftpCallback): void {
callVoid(WSL_HOOK_FS_METHODS.mkdir, { path }, callback)
}
}
return adapter as unknown as SFTPWrapper
}
@@ -0,0 +1,90 @@
// DI seam for WslHookRelayManager: the full dependency contract plus the
// production wiring. Tests construct the manager with fakes for everything
// that spawns wsl.exe or touches the live agentHookServer.
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { agentHookServer } from './server'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import {
isWslDistroRunning,
resolveWslHookRelayBundle,
runWslInstallProcess,
spawnWslRelayProcess
} from './wsl-hook-relay-launch'
import { waitForWslRelaySentinel } from './wsl-hook-relay-sentinel'
import { listWslDistrosAsync } from '../wsl'
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
// Why: fresh WSL intermittently throws "Catastrophic failure (E_UNEXPECTED)"
// under concurrent wsl.exe spawn load; the retry pause is a dep so tests can
// collapse it.
export const WSL_RELAY_TRANSIENT_RETRY_DELAY_MS = 2_000
// Restart/cooldown policy for the manager's state machine.
export const FAILURE_COOLDOWN_BASE_MS = 60_000
export const FAILURE_COOLDOWN_MAX_MS = 10 * 60_000
// Why: a distro without node >= 18 will not grow one mid-session; probe
// rarely instead of once per PTY spawn.
export const NO_NODE_COOLDOWN_MS = 10 * 60_000
// Why: a previously-healthy relay dying mid-session (mux protocol error, WSL
// restart) must self-recover — a live agent session produces no new PTY
// spawns, so waiting for the next ensure would leave status dead for good.
export const RUNNING_TEARDOWN_COOLDOWN_MS = 10_000
// Why: only a relay that stayed up this long resets the failure counter — a
// connect-then-crash loop must keep escalating its cooldown, not sit at the
// running-teardown base forever.
export const STABLE_UPTIME_MS = 2 * 60_000
// Why: re-running the (byte-equality idempotent) installers picks up configs
// that appear after first install — e.g. Codex's runtime-home config.toml is
// seeded by the launch path, so its hook-trust entries can only be written
// once that file exists. The one-shot timer covers single-spawn sessions.
export const REINSTALL_MIN_INTERVAL_MS = 30_000
export const REINSTALL_ONE_SHOT_DELAY_MS = 60_000
export type WslHookRelayManagerDeps = {
platform: () => NodeJS.Platform
remoteHooksEnabled: () => boolean
hookCoordsEnv: () => Record<string, string>
/** Restart-stable, instance-unique key for the guest endpoint dir. */
instanceKey: () => string | null
resolveBundle: typeof resolveWslHookRelayBundle
readBundle: (jsPath: string) => Buffer
listDistros: () => Promise<string[]>
isDistroRunning: typeof isWslDistroRunning
spawnRelay: typeof spawnWslRelayProcess
runInstall: typeof runWslInstallProcess
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
warn: (message: string) => void
transientRetryDelayMs: number
}
export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
platform: () => process.platform,
remoteHooksEnabled: () => isRemoteAgentHooksEnabled(),
hookCoordsEnv: () => agentHookServer.buildPtyEnv(),
// Why: the Windows endpoint file path (userData + namespace) is stable
// across app restarts and distinct per instance — exactly the identity the
// guest endpoint dir must carry so surviving agents re-coordinate.
instanceKey: () => {
const source = agentHookServer.endpointFilePath
return source ? createHash('sha256').update(source).digest('hex').slice(0, 12) : null
},
resolveBundle: resolveWslHookRelayBundle,
readBundle: (jsPath) => readFileSync(jsPath),
listDistros: () => listWslDistrosAsync(),
isDistroRunning: isWslDistroRunning,
spawnRelay: spawnWslRelayProcess,
runInstall: runWslInstallProcess,
waitForSentinel: waitForWslRelaySentinel,
ingest: (envelope, connectionId) =>
agentHookServer.ingestRemote(
envelope as Parameters<typeof agentHookServer.ingestRemote>[0],
connectionId
),
installHooks: installRemoteManagedAgentHooks,
warn: (message) => console.warn(message),
transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS
}
@@ -0,0 +1,330 @@
// Launch/install plumbing for the guest-resident WSL agent-hook relay:
// bundle resolution on the Windows side, the guest launch/install scripts,
// and the sentinel wait that turns a wsl.exe child's stdio into a
// MultiplexerTransport. Kept separate from the manager so the state machine
// stays readable. See docs/agent-status-over-wsl.md (STA-1515).
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import {
decodeWslText,
MAX_STARTUP_BUFFER_BYTES,
type waitForWslRelaySentinel,
type WslRelayStartupFailure
} from './wsl-hook-relay-sentinel'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import {
WSL_HOOK_RELAY_BUNDLE_NAME,
WSL_HOOK_RELAY_DIR,
WSL_HOOK_RELAY_INSTANCE_ENV,
WSL_HOOK_RELAY_NO_NODE_EXIT_CODE,
WSL_HOOK_RELAY_STALE_EXIT_CODE,
WSL_HOOK_RELAY_VERSION_ENV,
WSL_HOOK_RELAY_VERSION_FILE
} from '../../shared/wsl-hook-relay-contract'
const INSTALL_TIMEOUT_MS = 30_000
export type WslHookRelayBundle = { jsPath: string; version: string }
export function resolveWslHookRelayBundle(): WslHookRelayBundle | null {
// Mirrors getLocalRelayCandidates in ssh-relay-deploy: env override for
// tests/dev, then packaged extraResources, then dev out/ paths.
const candidates: string[] = []
if (process.env.ORCA_RELAY_PATH) {
candidates.push(join(process.env.ORCA_RELAY_PATH, 'wsl'))
}
if (process.resourcesPath) {
candidates.push(join(process.resourcesPath, 'relay', 'wsl'))
candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', 'wsl'))
}
try {
const appPath = app.getAppPath()
candidates.push(join(appPath, 'resources', 'relay', 'wsl'))
candidates.push(join(appPath, 'out', 'relay', 'wsl'))
} catch {
// app not ready in some test contexts — env/resources candidates suffice.
}
for (const dir of candidates) {
const jsPath = join(dir, WSL_HOOK_RELAY_BUNDLE_NAME)
const versionPath = join(dir, WSL_HOOK_RELAY_VERSION_FILE)
if (existsSync(jsPath) && existsSync(versionPath)) {
const version = readFileSync(versionPath, 'utf8').trim()
// Why: the version lands inside single-quoted guest shell text and in
// a guest path segment — refuse anything outside the safe alphabet.
if (/^[A-Za-z0-9+.-]+$/.test(version)) {
return { jsPath, version }
}
}
}
return null
}
// Why: the install dir is namespaced by bundle version so concurrent Orca
// instances with different bundles (dev + prod) never reinstall over each
// other; each instance launches exactly the version it shipped.
function guestRelayDirExpr(version: string): string {
return `$HOME/${WSL_HOOK_RELAY_DIR}/${version}`
}
/** Guest launcher, installed alongside the bundle. The `.version` marker is
* written last by the installer, so the check rejects partial installs;
* node resolution probes each candidate's version because `sh -c` does not
* source interactive profiles (an apt node 12 on PATH must not shadow an
* nvm node 20 off PATH). */
export function buildGuestLaunchScript(version: string): string {
const dir = guestRelayDirExpr(version)
return [
'#!/bin/sh',
`d="${dir}"`,
`v="$(cat "$d/${WSL_HOOK_RELAY_VERSION_FILE}" 2>/dev/null || true)"`,
`[ -n "$${WSL_HOOK_RELAY_VERSION_ENV}" ] && [ "$v" = "$${WSL_HOOK_RELAY_VERSION_ENV}" ] || exit ${WSL_HOOK_RELAY_STALE_EXIT_CODE}`,
'n=""',
'for c in "$(command -v node 2>/dev/null || true)" "$HOME/.nvm/versions/node"/*/bin/node /usr/local/bin/node /usr/bin/node "$HOME/.local/bin/node"; do',
' [ -n "$c" ] && [ -x "$c" ] || continue',
` if "$c" -e 'process.exit(Number(process.versions.node.split(".")[0])>=18?0:1)' 2>/dev/null; then`,
' n="$c"',
' break',
' fi',
'done',
`[ -n "$n" ] || exit ${WSL_HOOK_RELAY_NO_NODE_EXIT_CODE}`,
`exec "$n" "$d/${WSL_HOOK_RELAY_BUNDLE_NAME}"`,
''
].join('\n')
}
/** Idempotent install script, piped to `sh -s` over stdin. Heredocs with
* quoted delimiters carry the bundle (base64) and launcher verbatim, so no
* argv quoting crosses the wsl.exe boundary. Tmp names carry the guest PID
* so same-version concurrent installs cannot corrupt each other. */
export function buildGuestInstallScript(bundleJs: Buffer, version: string): string {
const b64 = bundleJs.toString('base64').replace(/(.{1,120})/g, '$1\n')
return [
'set -e',
'umask 077',
`d="${guestRelayDirExpr(version)}"`,
'mkdir -p "$d"',
`base64 -d > "$d/bundle.$$.tmp" << 'ORCA_EOF_BUNDLE'`,
b64.trimEnd(),
'ORCA_EOF_BUNDLE',
`mv "$d/bundle.$$.tmp" "$d/${WSL_HOOK_RELAY_BUNDLE_NAME}"`,
`cat > "$d/launch.$$.tmp" << 'ORCA_EOF_LAUNCH'`,
buildGuestLaunchScript(version).trimEnd(),
'ORCA_EOF_LAUNCH',
'mv "$d/launch.$$.tmp" "$d/launch.sh"',
'chmod 700 "$d/launch.sh"',
// Version marker last: a partial install stays "stale" and reinstalls.
`printf '%s' '${version}' > "$d/${WSL_HOOK_RELAY_VERSION_FILE}"`,
''
].join('\n')
}
export function spawnWslRelayProcess(
distro: string,
env: NodeJS.ProcessEnv,
version: string
): ChildProcessWithoutNullStreams {
// Why: --exec bypasses the distro's default login shell — a bare `--`
// routes through it (a fish/nushell chsh could mangle the command) and
// triggers wsl.exe's `$`-preprocessing of Windows argv. --exec passes argv
// verbatim (same form as the Codex WSL login spawn), so `$HOME` reaches
// sh unescaped and expands guest-side.
const command = `exec sh "${guestRelayDirExpr(version)}/launch.sh"`
return spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-c', command], {
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
}
/** True when the distro shows in `wsl --list --running`. Listing does NOT
* boot anything — unlike `wsl -d`, which starts a stopped distro. The
* restart timer must check this so relay recovery never resurrects a VM the
* user shut down with `wsl --shutdown`. Fails CLOSED (false) on probe
* errors: booting a VM the user shut down is worse than a skipped restart
* (the next WSL PTY spawn re-ensures), and a wsl.exe too wedged to list
* distros would not have launched the relay anyway. */
export function isWslDistroRunning(distro: string): Promise<boolean> {
return new Promise((resolve) => {
execFile(
'wsl.exe',
['--list', '--running', '--quiet'],
// Why: WSL_UTF8=1 forces UTF-8 output; without it wsl.exe emits
// UTF-16LE that reads as NUL-riddled text.
{ env: { ...process.env, WSL_UTF8: '1' }, timeout: 10_000, windowsHide: true },
(err, stdout) => {
if (err) {
resolve(false)
return
}
const wanted = distro.trim().toLowerCase()
const running = decodeWslText(String(stdout))
.split(/\r?\n/)
.map((line) => line.trim().toLowerCase())
.filter(Boolean)
resolve(running.includes(wanted))
}
)
})
}
export function runWslInstallProcess(
distro: string,
script: string,
env: NodeJS.ProcessEnv
): Promise<{ code: number | null; stderr: string }> {
return new Promise((resolve, reject) => {
// Why: --exec skips the default login shell; the script rides stdin so
// no quoting crosses the wsl.exe boundary at all.
const child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s'], {
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
let stderr = ''
let settled = false
// Why: a wedged wsl.exe here would otherwise pin the manager's state at
// 'starting' forever — the one unbounded await on the ensure path.
const timeout = setTimeout(() => {
if (!settled) {
settled = true
child.kill()
resolve({
code: null,
stderr: `${stderr}\ninstall timed out after ${INSTALL_TIMEOUT_MS}ms`
})
}
}, INSTALL_TIMEOUT_MS)
child.stderr.on('data', (d: Buffer) => {
stderr = (stderr + decodeWslText(d.toString('utf8'))).slice(-MAX_STARTUP_BUFFER_BYTES)
})
child.on('error', (err) => {
if (!settled) {
settled = true
clearTimeout(timeout)
reject(err)
}
})
child.on('close', (code) => {
if (!settled) {
settled = true
clearTimeout(timeout)
resolve({ code, stderr })
}
})
child.stdin.on('error', () => {
// Guest exited before consuming stdin — surfaced via close/code.
})
child.stdin.write(script)
child.stdin.end()
})
}
const TRANSIENT_RETRY_LIMIT = 2
export type WslRelayLaunchIo = {
spawnRelay: typeof spawnWslRelayProcess
waitForSentinel: typeof waitForWslRelaySentinel
runInstall: typeof runWslInstallProcess
readBundle: (jsPath: string) => Buffer
transientRetryDelayMs: number
}
/** Spawn → sentinel → connect, with the guest-install/retry policy: stale or
* missing installs get exactly one streamed reinstall, wsl.exe's transient
* "Catastrophic failure (E_UNEXPECTED)" gets a bounded retry, a distro
* without node >= 18 reports through `onNoNode`. Terminal failures report
* through `onFailure`; non-startup errors propagate to the caller. */
export async function launchWslRelayWithInstall(options: {
distro: string
env: NodeJS.ProcessEnv
bundleJsPath: string
version: string
io: WslRelayLaunchIo
isDisposed: () => boolean
onChild: (child: ChildProcessWithoutNullStreams) => void
onNoNode: () => void
onFailure: (message: string) => void
connect: (transport: MultiplexerTransport, child: ChildProcessWithoutNullStreams) => Promise<void>
}): Promise<void> {
const { distro, env, bundleJsPath, version, io } = options
let installTried = false
let transientRetries = 0
for (;;) {
if (options.isDisposed()) {
return
}
const child = io.spawnRelay(distro, env, version)
options.onChild(child)
try {
const transport = await io.waitForSentinel(child)
await options.connect(transport, child)
return
} catch (err) {
const failure = (err as { startup?: WslRelayStartupFailure }).startup
if (!failure) {
throw err
}
if (failure.code === WSL_HOOK_RELAY_NO_NODE_EXIT_CODE) {
options.onNoNode()
return
}
if (
/catastrophic failure/i.test(failure.stderr) &&
transientRetries < TRANSIENT_RETRY_LIMIT
) {
transientRetries++
await new Promise((resolve) => setTimeout(resolve, io.transientRetryDelayMs))
continue
}
if (!installTried) {
installTried = true
const script = buildGuestInstallScript(io.readBundle(bundleJsPath), version)
const result = await io.runInstall(distro, script, env)
if (result.code === 0) {
continue
}
options.onFailure(
`guest install failed (code ${result.code ?? 'unknown'}): ${result.stderr.trim()}`
)
return
}
options.onFailure(formatWslRelayFailure(failure))
return
}
}
}
export function formatWslRelayFailure(failure: WslRelayStartupFailure): string {
const detail = failure.stderr.trim()
return `startup failed (${failure.kind}, code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}`
}
/** Env for the relay's wsl.exe spawn: the live hook coordinates, the
* host-expected bundle version, and the stable instance key, all crossed
* via WSLENV. WSL_UTF8 keeps wsl.exe's own error text (e.g. "Catastrophic
* failure") UTF-8 so stderr matching and breadcrumbs stay readable. */
export function buildWslRelaySpawnEnv(
coords: Record<string, string>,
bundleVersion: string,
instanceKey: string
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
WSL_UTF8: '1',
ORCA_AGENT_HOOK_PORT: coords.ORCA_AGENT_HOOK_PORT,
ORCA_AGENT_HOOK_TOKEN: coords.ORCA_AGENT_HOOK_TOKEN,
ORCA_AGENT_HOOK_ENV: coords.ORCA_AGENT_HOOK_ENV,
ORCA_AGENT_HOOK_VERSION: coords.ORCA_AGENT_HOOK_VERSION,
[WSL_HOOK_RELAY_VERSION_ENV]: bundleVersion,
[WSL_HOOK_RELAY_INSTANCE_ENV]: instanceKey
}
// Why: the relay derives its own guest endpoint path; a /p-translated
// Windows endpoint here would only add WSLENV noise.
delete env.ORCA_AGENT_HOOK_ENDPOINT
addOrcaWslInteropEnv(env as Record<string, string>)
return env
}
@@ -0,0 +1,58 @@
// Envelope forwarding and death handling for one live WSL relay link.
// Extracted from the manager so its state machine stays readable; the manager
// decides what a dead link means (cooldown, restart), this module guarantees
// it finds out exactly once, whichever signal fires first.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_NOTIFICATION_METHOD } from '../../shared/agent-hook-relay'
import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
export type WslRelayLinkOptions = {
mux: SshChannelMultiplexer
child: ChildProcessWithoutNullStreams
distro: string
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
warn: (message: string) => void
/** Called exactly once when the link dies — from EITHER a mux dispose
* (protocol error, keepalive timeout) or the child exiting. A mux death
* without child death would otherwise blackhole every later envelope
* while the guest keeps returning 204 to hook clients. */
onDead: (reason: string) => void
}
export function wireWslRelayLink(options: WslRelayLinkOptions): void {
const { mux, child, distro, ingest, warn, onDead } = options
const connectionId = wslHookRelayConnectionId(distro)
mux.onNotification((method, params) => {
if (method !== AGENT_HOOK_NOTIFICATION_METHOD) {
return
}
if (typeof (params as { paneKey?: unknown }).paneKey !== 'string') {
return
}
if (process.env.ORCA_WSL_HOOK_RELAY_DEBUG === '1') {
const p = params as { paneKey?: string; payload?: { state?: string } }
warn(
`[agent-hooks] WSL relay envelope (${distro}): pane=${p.paneKey} state=${p.payload?.state ?? '?'}`
)
}
// Trust boundary: ingestRemote re-validates paneKey/tabId and
// re-normalizes the payload, same as the SSH relay path.
ingest(params, connectionId)
})
let dead = false
const die = (reason: string): void => {
if (dead) {
return
}
dead = true
mux.dispose()
child.kill()
onDead(reason)
}
mux.onDispose((reason) => die(`mux disposed (${reason})`))
child.on('close', () => die('process exited'))
}
@@ -0,0 +1,203 @@
// Live end-to-end oracle for the WSL hook relay HOST side: the real esbuild
// bundle runs as a real child process (spawned via `node` instead of wsl.exe
// — everything else identical), the real manager connects over the child's
// actual stdio pipes, the real installers write through the fs bridge, and a
// real HTTP POST in the exact Claude hook shape must land in a real
// AgentHookServer.ingestRemote. This is the chain the Windows-rig GUI run
// exercises minus the wsl.exe byte transport (validated separately on-rig).
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
const LEAF = '11111111-1111-4111-8111-111111111111'
async function pickFreePort(): Promise<number> {
const probe = createServer()
const port = await new Promise<number>((resolve) => {
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
await new Promise<void>((resolve) => probe.close(() => resolve()))
return port
}
// Why skipIf: the guest is always POSIX — the bundle derives its home from
// $HOME, which os.homedir() ignores on Windows. Windows coverage comes from
// the live rig runs against a real distro.
describe.skipIf(process.platform === 'win32')(
'WSL hook relay live host chain (real bundle over real child stdio)',
() => {
let fakeHome: string
let manager: WslHookRelayManager | null
let orcaServer: AgentHookServer | null
let child: ChildProcessWithoutNullStreams | null
beforeAll(() => {
if (!existsSync(BUNDLE_JS)) {
execFileSync(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], {
cwd: process.cwd(),
stdio: 'ignore'
})
}
}, 120_000)
afterEach(() => {
manager?.disposeAll()
orcaServer?.stop()
child?.kill()
rmSync(fakeHome, { recursive: true, force: true })
})
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-'))
const preferredPort = await pickFreePort()
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
orcaServer = new AgentHookServer()
const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = []
orcaServer.setListener((event) => {
events.push({
paneKey: event.paneKey,
payload: event.payload,
connectionId: event.connectionId
})
})
const server = orcaServer
const warns: string[] = []
manager = new WslHookRelayManager({
platform: () => 'win32',
remoteHooksEnabled: () => true,
hookCoordsEnv: () => ({
ORCA_AGENT_HOOK_PORT: String(preferredPort),
ORCA_AGENT_HOOK_TOKEN: 'live-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: '1'
}),
instanceKey: () => 'liveinstance',
resolveBundle: () => ({ jsPath: BUNDLE_JS, version }),
listDistros: async () => ['LiveDistro'],
spawnRelay: (_distro, env) => {
child = spawn(process.execPath, [BUNDLE_JS], {
env: { ...env, HOME: fakeHome },
stdio: ['pipe', 'pipe', 'pipe']
}) as ChildProcessWithoutNullStreams
return child
},
runInstall: async () => {
throw new Error('guest install must not run for a direct node spawn')
},
ingest: (envelope, connectionId) =>
server.ingestRemote(
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
connectionId
),
warn: (message) => warns.push(message),
transientRetryDelayMs: 1
})
manager.ensureForDistro('LiveDistro')
// Codex hooks land in the redirected managed runtime home. Waiting on
// this artifact (not Claude's, which is written first) keeps the
// assertions behind the still-running 14-agent installer loop.
const codexRuntimeHome = join(
fakeHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
await vi.waitFor(() => expect(existsSync(join(codexRuntimeHome, 'hooks.json'))).toBe(true), {
timeout: 15_000
})
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
const claudeScript = readFileSync(
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
'utf8'
)
expect(claudeScript).toContain('/hook/claude')
// Trust TOML is deferred so the launch-path seed is never pre-empted.
expect(existsSync(join(codexRuntimeHome, 'config.toml'))).toBe(false)
expect(existsSync(join(fakeHome, '.codex', 'hooks.json'))).toBe(false)
// Re-coordinate exactly like a hook script: read the relay-written
// endpoint file rather than assuming the preferred port bind won.
const endpointFile = join(
fakeHome,
'.orca-wsl',
'agent-hooks',
'instance-liveinstance',
'endpoint.env'
)
expect(existsSync(endpointFile)).toBe(true)
const endpointText = readFileSync(endpointFile, 'utf8')
const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(endpointText)?.[1])
const token = /ORCA_AGENT_HOOK_TOKEN=['"]?([A-Za-z0-9-]+)/.exec(endpointText)?.[1]
expect(port).toBeGreaterThan(0)
expect(token).toBe('live-token')
const paneKey = `tab-live:${LEAF}`
const postClaude = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token ?? ''
},
body: JSON.stringify({
paneKey,
tabId: 'tab-live',
worktreeId: 'wt-live',
env: 'remote',
version: '1',
payload
})
})
const promptRes = await postClaude({
hook_event_name: 'UserPromptSubmit',
prompt: 'live roundtrip'
})
expect(promptRes.status).toBe(204)
await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), { timeout: 10_000 })
expect(events[0].paneKey).toBe(paneKey)
expect(events[0].connectionId).toBe('wsl:LiveDistro')
const working = events[0].payload as { state: string; prompt: string; agentType: string }
expect(working.state).toBe('working')
expect(working.prompt).toBe('live roundtrip')
expect(working.agentType).toBe('claude')
const stopRes = await postClaude({ hook_event_name: 'Stop' })
expect(stopRes.status).toBe(204)
await vi.waitFor(
() => {
const done = events.find((e) => (e.payload as { state?: string }).state === 'done')
expect(done).toBeTruthy()
},
{ timeout: 10_000 }
)
// Link death must be breadcrumbed and scheduled for restart — a silent
// mux/child death would blackhole every later envelope while the guest
// keeps returning 204 (the exact failure signature from the Windows rig).
child?.kill()
await vi.waitFor(
() => expect(warns.some((w) => w.includes('scheduling restart'))).toBe(true),
{ timeout: 10_000 }
)
}, 40_000)
}
)
@@ -0,0 +1,389 @@
// WSL hook relay host side: the SFTP-shaped fs adapter over the relay's fs
// bridge (including a full run of the unchanged remote hook installers), and
// the per-distro relay manager state machine with fault injection.
import { EventEmitter } from 'node:events'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayDispatcher } from '../../relay/dispatcher'
import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import {
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../../shared/agent-hook-relay'
type GuestHarness = {
transport: MultiplexerTransport
guestDispatcher: RelayDispatcher
mux: SshChannelMultiplexer
}
/** In-memory stdio pair: host mux on one end, guest dispatcher on the other
* (same harness shape as the relay agent-hook integration test). */
function createGuestHarness(): GuestHarness {
let relayFeed: ((data: Buffer) => void) | undefined
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
const transport: MultiplexerTransport = {
write: (data) => {
setImmediate(() => relayFeed?.(data))
},
onData: (cb) => {
clientDataCallbacks.push(cb)
},
onClose: (cb) => {
closeCallbacks.push(cb)
}
}
const guestDispatcher = new RelayDispatcher((data: Buffer) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
})
relayFeed = (data) => guestDispatcher.feed(data)
const mux = new SshChannelMultiplexer(transport)
return { transport, guestDispatcher, mux }
}
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by
// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the
// bridge correctly refuses; Windows coverage comes from the live rig runs.
describe.skipIf(process.platform === 'win32')(
'createWslHookSftpAdapter over the guest fs bridge',
() => {
let home: string
let harness: GuestHarness
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
harness = createGuestHarness()
registerWslHookFsHandlers(harness.guestDispatcher, home)
})
afterEach(() => {
harness.mux.dispose()
harness.guestDispatcher.dispose()
rmSync(home, { recursive: true, force: true })
})
it('maps guest ENOENT onto ssh2 status code 2', async () => {
const adapter = createWslHookSftpAdapter(harness.mux)
const err = await new Promise<Error & { code?: number }>((resolve) => {
adapter.readFile(`${home}/missing.json`, 'utf8', ((e: Error) => resolve(e)) as never)
})
expect(err).toBeInstanceOf(Error)
expect(err.code).toBe(2)
})
it('round-trips write/read/stat/rename and rejects paths outside home', async () => {
const adapter = createWslHookSftpAdapter(harness.mux)
await new Promise<void>((resolve, reject) => {
adapter.writeFile(
`${home}/a.txt`,
'hello',
{ encoding: 'utf8', mode: 0o600 } as never,
((e: Error | null) => (e ? reject(e) : resolve())) as never
)
})
const content = await new Promise<string>((resolve, reject) => {
adapter.readFile(`${home}/a.txt`, 'utf8', ((e: Error | null, value: string) =>
e ? reject(e) : resolve(value)) as never)
})
expect(content).toBe('hello')
await new Promise<void>((resolve, reject) => {
adapter.ext_openssh_rename(`${home}/a.txt`, `${home}/b.txt`, ((e: Error | null) =>
e ? reject(e) : resolve()) as never)
})
expect(existsSync(`${home}/b.txt`)).toBe(true)
const outside = await new Promise<Error & { code?: number }>((resolve) => {
adapter.readFile('/etc/passwd', 'utf8', ((e: Error) => resolve(e)) as never)
})
expect(outside).toBeInstanceOf(Error)
})
it('runs the unchanged remote managed hook installers against a WSL guest home', async () => {
const adapter = createWslHookSftpAdapter(harness.mux)
const results = await installRemoteManagedAgentHooks(adapter, home)
expect(results.length).toBeGreaterThan(0)
expect(results.every((r) => r.state !== 'error')).toBe(true)
const claudeSettings = JSON.parse(
readFileSync(join(home, '.claude', 'settings.json'), 'utf8')
)
expect(claudeSettings.hooks).toBeTruthy()
const script = readFileSync(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8')
expect(script).toContain('/hook/claude')
}, 20_000)
}
)
describe('WslHookRelayManager', () => {
// Why: a fixed POSIX guest home keeps this suite runnable on Windows dev
// hosts — installHooks is mocked here, so the fs bridge only ever serves
// the wslfs.home request and never touches the real filesystem.
const home = '/home/wsl-test-user'
let harnesses: GuestHarness[]
beforeEach(() => {
harnesses = []
})
afterEach(() => {
for (const h of harnesses) {
h.mux.dispose()
h.guestDispatcher.dispose()
}
})
function fakeChild(): ChildProcessWithoutNullStreams & { emitClose: () => void } {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: { write: () => boolean; end: () => void; on: () => void }
kill: () => void
emitClose: () => void
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.stdin = { write: () => true, end: () => {}, on: () => {} }
child.kill = () => {}
child.emitClose = () => child.emit('close', 0)
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
}
function guestTransport(): MultiplexerTransport {
const harness = createGuestHarness()
harnesses.push(harness)
registerWslHookFsHandlers(harness.guestDispatcher, home)
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: 0
}))
return harness.transport
}
function startupError(code: number | null, stderr = ''): Error {
return Object.assign(new Error(`exit ${code}`), {
startup: { kind: 'exit' as const, code, stderr }
})
}
function createManager(overrides: Partial<WslHookRelayManagerDeps>): {
manager: WslHookRelayManager
deps: WslHookRelayManagerDeps
} {
const deps: WslHookRelayManagerDeps = {
platform: () => 'win32',
remoteHooksEnabled: () => true,
hookCoordsEnv: () => ({
ORCA_AGENT_HOOK_PORT: '43117',
ORCA_AGENT_HOOK_TOKEN: 'tok',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: '1'
}),
instanceKey: () => 'testinstance',
resolveBundle: () => ({ jsPath: '/fake/wsl-agent-hook-relay.js', version: '0.1.0+abc' }),
readBundle: () => Buffer.from('// bundle'),
listDistros: async () => ['Ubuntu'],
isDistroRunning: vi.fn(async () => true),
spawnRelay: vi.fn(() => fakeChild()),
runInstall: vi.fn(async () => ({ code: 0, stderr: '' })),
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
warn: vi.fn(),
transientRetryDelayMs: 1,
...overrides
}
return { manager: new WslHookRelayManager(deps), deps }
}
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
// Codex is the one agent whose home Orca redirects for WSL sessions.
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`
})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
`${home}/.orca-wsl/agent-hooks/instance-testinstance/endpoint.env`
)
const guest = harnesses[0].guestDispatcher
guest.notify(AGENT_HOOK_NOTIFICATION_METHOD, {
paneKey: 'tab:leaf',
payload: { state: 'working' }
})
await vi.waitFor(() =>
expect(deps.ingest).toHaveBeenCalledWith(
expect.objectContaining({ paneKey: 'tab:leaf' }),
'wsl:Ubuntu'
)
)
guest.notify(AGENT_HOOK_NOTIFICATION_METHOD, { payload: { state: 'working' } })
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.ingest).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro(null)
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro(null)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
expect(manager.getGuestEndpointFilePath(null)).toBe(
`${home}/.orca-wsl/agent-hooks/instance-testinstance/endpoint.env`
)
manager.disposeAll()
})
it('reinstalls once on a stale-version exit (42) and then connects', async () => {
const waitForSentinel = vi
.fn()
.mockRejectedValueOnce(startupError(42))
.mockImplementationOnce(async () => guestTransport())
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.runInstall).toHaveBeenCalledTimes(1)
expect(deps.spawnRelay).toHaveBeenCalledTimes(2)
manager.disposeAll()
})
it('gives up without installing when the guest has no node (43)', async () => {
const waitForSentinel = vi.fn().mockRejectedValue(startupError(43))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() =>
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('no node'))
)
expect(deps.runInstall).not.toHaveBeenCalled()
// Cooldown: an immediate re-ensure must not spawn again.
manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('retries a bounded number of times on catastrophic wsl.exe failures', async () => {
const waitForSentinel = vi
.fn()
.mockRejectedValueOnce(startupError(1, 'Catastrophic failure (E_UNEXPECTED)'))
.mockRejectedValueOnce(startupError(1, 'Catastrophic failure (E_UNEXPECTED)'))
.mockImplementationOnce(async () => guestTransport())
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.spawnRelay).toHaveBeenCalledTimes(3)
expect(deps.runInstall).not.toHaveBeenCalled()
manager.disposeAll()
})
it('marks the distro failed when the relay exits and re-ensures only after cooldown', async () => {
const children: ReturnType<typeof fakeChild>[] = []
const spawnRelay = vi.fn(() => {
const child = fakeChild()
children.push(child)
return child
})
const { manager, deps } = createManager({ spawnRelay })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
children[0].emitClose()
await vi.waitFor(() =>
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('exited'))
)
manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(spawnRelay).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('is inert off-Windows and when remote hooks are disabled', async () => {
const offPlatform = createManager({ platform: () => 'darwin' })
offPlatform.manager.ensureForDistro('Ubuntu')
const disabled = createManager({ remoteHooksEnabled: () => false })
disabled.manager.ensureForDistro('Ubuntu')
await new Promise((resolve) => setTimeout(resolve, 20))
expect(offPlatform.deps.spawnRelay).not.toHaveBeenCalled()
expect(disabled.deps.spawnRelay).not.toHaveBeenCalled()
})
it('requires WSL fs-bridge home coordinates before exposing an endpoint path', () => {
const { manager } = createManager({})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()
expect(manager.getGuestEndpointFilePath(null)).toBeNull()
})
it('keeps a fresh state that replaced a failed one while its restart probe was in flight', async () => {
// Drain microtasks under fake timers (queueMicrotask is not a faked timer).
const flush = async (): Promise<void> => {
for (let i = 0; i < 25; i++) {
await Promise.resolve()
}
}
let resolveProbe: ((running: boolean) => void) | undefined
const isDistroRunning = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
const spawnRelay = vi.fn(() => fakeChild())
// First launch fails outright; the replacement launch never reaches the
// sentinel, so its state stays 'starting' with no live mux to clean up.
const waitForSentinel = vi
.fn()
.mockRejectedValueOnce(new Error('relay died before sentinel'))
.mockReturnValueOnce(new Promise<never>(() => {}))
const { manager, deps } = createManager({ isDistroRunning, spawnRelay, waitForSentinel })
vi.useFakeTimers()
try {
manager.ensureForDistro('Ubuntu')
await flush()
expect(spawnRelay).toHaveBeenCalledTimes(1)
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('relay died before sentinel'))
// Fire the restart timer; recovery blocks awaiting the distro-running probe.
await vi.advanceTimersByTimeAsync(FAILURE_COOLDOWN_BASE_MS + 300)
expect(isDistroRunning).toHaveBeenCalledTimes(1)
// A new WSL PTY spawn re-ensures past the elapsed cooldown, replacing the
// failed state in the map while the old state's probe is still pending.
manager.ensureForDistro('Ubuntu')
await flush()
expect(spawnRelay).toHaveBeenCalledTimes(2)
// Probe resolves 'not running' after the swap: the drop must be skipped so
// the replacement's live relay is not orphaned.
resolveProbe?.(false)
await flush()
expect(deps.warn).not.toHaveBeenCalledWith(expect.stringContaining('distro not running'))
// Fresh state survived: a further ensure dedupes instead of spawning again.
manager.ensureForDistro('Ubuntu')
await flush()
expect(spawnRelay).toHaveBeenCalledTimes(2)
} finally {
manager.disposeAll()
vi.useRealTimers()
}
})
})
@@ -0,0 +1,328 @@
// Host-side lifecycle manager for the guest-resident WSL agent-hook relay
// (STA-1515): one relay per distro per instance, ensured from every WSL PTY
// spawn, forwarding envelopes into ingestRemote and installing guest hooks.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
import { buildWslRelaySpawnEnv, launchWslRelayWithInstall } from './wsl-hook-relay-launch'
import {
defaultWslHookRelayDeps,
FAILURE_COOLDOWN_BASE_MS,
FAILURE_COOLDOWN_MAX_MS,
NO_NODE_COOLDOWN_MS,
REINSTALL_MIN_INTERVAL_MS,
REINSTALL_ONE_SHOT_DELAY_MS,
RUNNING_TEARDOWN_COOLDOWN_MS,
STABLE_UPTIME_MS,
type WslHookRelayManagerDeps
} from './wsl-hook-relay-deps'
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
import {
sanitizeWslHookInstanceKey,
WSL_HOOK_FS_METHODS,
wslHookRelayEndpointFilePath
} from '../../shared/wsl-hook-relay-contract'
type DistroState = {
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
distro: string
phase: 'starting' | 'running' | 'failed'
child?: ChildProcessWithoutNullStreams
mux?: SshChannelMultiplexer
guestHome?: string
guestEndpointFilePath?: string
failures: number
cooldownUntil: number
connectedAt?: number
restartTimer?: ReturnType<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
lastInstallAt?: number
}
function distroKey(distro: string): string {
return distro.trim().toLowerCase()
}
export class WslHookRelayManager {
private deps: WslHookRelayManagerDeps
private recovery: WslRelayRecovery
private states = new Map<string, DistroState>()
private defaultDistro: string | null = null
private disposed = false
private warnedBundleMissing = false
constructor(deps: Partial<WslHookRelayManagerDeps> = {}) {
this.deps = { ...defaultWslHookRelayDeps, ...deps }
this.recovery = new WslRelayRecovery({
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
warn: (message) => this.deps.warn(message),
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(distroKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
const key = distroKey(state.distro)
if (this.states.get(key) === state) {
this.states.delete(key)
}
}
})
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
return
}
void this.ensureInternal(distro).catch((err) => {
this.deps.warn(
`[agent-hooks] WSL hook relay ensure failed: ${err instanceof Error ? err.message : String(err)}`
)
})
}
/** Guest endpoint file path once known; null before first connect
* (callers keep the /p-translated Windows endpoint path until then). */
getGuestEndpointFilePath(distro: string | null): string | null {
const name = distro ?? this.defaultDistro
if (!name) {
return null
}
return this.states.get(distroKey(name))?.guestEndpointFilePath ?? null
}
disposeAll(): void {
this.disposed = true
for (const state of this.states.values()) {
this.recovery.clearTimers(state)
state.mux?.dispose()
state.child?.kill()
}
this.states.clear()
}
private async ensureInternal(requestedDistro: string | null): Promise<void> {
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
if (!distro || this.disposed) {
return
}
const key = distroKey(distro)
const existing = this.states.get(key)
if (existing) {
if (existing.phase === 'running') {
void this.maybeReinstallHooks(existing)
return
}
if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) {
return
}
}
const coords = this.deps.hookCoordsEnv()
const port = Number(coords.ORCA_AGENT_HOOK_PORT ?? '')
if (!Number.isInteger(port) || port <= 0 || !coords.ORCA_AGENT_HOOK_TOKEN) {
return
}
const bundle = this.deps.resolveBundle()
if (!bundle) {
if (!this.warnedBundleMissing) {
this.warnedBundleMissing = true
this.deps.warn('[agent-hooks] WSL hook relay bundle not found; run build:relay')
}
return
}
// Why: restart-stable instance identity keeps the guest endpoint file at
// ONE path across restarts so daemon-surviving agents re-coordinate.
const instanceKey =
sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}`
if (existing) {
this.recovery.clearTimers(existing)
}
const state: DistroState = {
distro,
phase: 'starting',
failures: existing?.failures ?? 0,
cooldownUntil: 0
}
this.states.set(key, state)
const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey)
try {
await launchWslRelayWithInstall({
distro: state.distro,
env,
bundleJsPath: bundle.jsPath,
version: bundle.version,
io: this.deps,
isDisposed: () => this.disposed,
onChild: (child) => {
state.child = child
},
onNoNode: () =>
this.markFailed(
state,
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
),
onFailure: (message) =>
this.markFailed(state, message, { cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS }),
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
})
} catch (err) {
// Why: teardown may have already recorded this failure; don't double-
// count. A request-level error can leave a live child — never leak it.
state.child?.kill()
state.mux?.dispose()
if (state.phase !== 'failed') {
this.markFailed(state, err instanceof Error ? err.message : String(err), {
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
})
}
}
}
private async connect(
state: DistroState,
transport: MultiplexerTransport,
child: ChildProcessWithoutNullStreams,
instanceKey: string
): Promise<void> {
const mux = new SshChannelMultiplexer(transport)
state.mux = mux
wireWslRelayLink({
mux,
child,
distro: state.distro,
ingest: this.deps.ingest,
warn: this.deps.warn,
onDead: (reason) => {
if (this.disposed || state.mux !== mux) {
return
}
state.mux = undefined
const wasRunning = state.phase === 'running'
// Why: only a stable run forgives past failures — a connect-then-die
// loop must escalate, not retry every 10s.
if (
wasRunning &&
state.connectedAt !== undefined &&
Date.now() - state.connectedAt >= STABLE_UPTIME_MS
) {
state.failures = 0
}
this.markFailed(state, `relay link for '${state.distro}' ${reason}; scheduling restart`, {
cooldownBaseMs: wasRunning ? RUNNING_TEARDOWN_COOLDOWN_MS : FAILURE_COOLDOWN_BASE_MS
})
}
})
const homeResult = (await mux.request(WSL_HOOK_FS_METHODS.home)) as {
ok?: boolean
home?: string
portFallback?: boolean
boundPort?: number
}
if (homeResult?.ok !== true || typeof homeResult.home !== 'string') {
throw new Error(`relay for '${state.distro}' returned no home dir`)
}
if (homeResult.portFallback === true) {
this.deps.warn(
`[agent-hooks] WSL hook relay (${state.distro}): preferred port occupied in guest; bound ${homeResult.boundPort ?? 'unknown'} (endpoint-file re-coordination)`
)
}
state.guestHome = homeResult.home
state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey)
await this.runInstallers(state, mux, homeResult.home)
if (state.phase === 'failed' || state.mux !== mux) {
// Child died while installing — already recorded; don't revive.
return
}
state.phase = 'running'
state.connectedAt = Date.now()
// Why: one-shot catch-up so a single-spawn session (no later ensure)
// still writes Codex's deferred trust after the launch path seeds config.toml.
this.recovery.scheduleOneShotReinstall(state, REINSTALL_ONE_SHOT_DELAY_MS, () => {
void this.maybeReinstallHooks(state)
})
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {
// Fresh relays have nothing to replay; tolerate.
})
}
private async runInstallers(
state: DistroState,
mux: SshChannelMultiplexer,
guestHome: string
): Promise<void> {
state.lastInstallAt = Date.now()
await installWslGuestHooks({
mux,
guestHome,
distro: state.distro,
installHooks: this.deps.installHooks,
warn: this.deps.warn
})
}
private async maybeReinstallHooks(state: DistroState): Promise<void> {
const mux = state.mux
const guestHome = state.guestHome
if (
!mux ||
!guestHome ||
mux.isDisposed() ||
Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS
) {
return
}
try {
await this.runInstallers(state, mux, guestHome)
} catch (err) {
this.deps.warn(
`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
/** Records + breadcrumbs the failure and always arms the restart timer —
* one failed relaunch must not end self-recovery; the timer's
* distro-running probe keeps this from booting stopped distros. */
private markFailed(
state: DistroState,
message: string,
options: { cooldownBaseMs: number }
): void {
state.phase = 'failed'
state.failures++
state.child = undefined
state.mux = undefined
if (state.reinstallTimer) {
clearTimeout(state.reinstallTimer)
state.reinstallTimer = undefined
}
state.cooldownUntil =
Date.now() + Math.min(options.cooldownBaseMs * state.failures, FAILURE_COOLDOWN_MAX_MS)
this.deps.warn(`[agent-hooks] WSL hook relay (${state.distro}): ${message}`)
this.recovery.scheduleRestart(state)
}
private async resolveDefaultDistro(): Promise<string | null> {
if (this.defaultDistro) {
return this.defaultDistro
}
try {
const distros = await this.deps.listDistros()
this.defaultDistro = distros[0] ?? null
} catch {
this.defaultDistro = null
}
return this.defaultDistro
}
}
export const wslHookRelayManager = new WslHookRelayManager()
@@ -0,0 +1,155 @@
// Restart-timer policy: recovery must re-ensure a running distro, must NOT
// boot a stopped one (wsl -d starts stopped distros), and must respect state
// currency and disposal.
import { describe, expect, it, vi } from 'vitest'
import { WslRelayRecovery, type WslRelayRecoveryState } from './wsl-hook-relay-recovery'
function makeState(): WslRelayRecoveryState {
return { distro: 'Ubuntu', cooldownUntil: Date.now() - 1_000 }
}
function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise<void> {
return vi.waitFor(() => expect(condition()).toBe(true), { timeout: timeoutMs })
}
describe('WslRelayRecovery', () => {
it('re-ensures the distro when the restart timer fires and the distro is running', async () => {
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => restart.mock.calls.length === 1)
expect(restart).toHaveBeenCalledWith('Ubuntu')
})
it('drops the state instead of booting a stopped distro', async () => {
const restart = vi.fn()
const dropState = vi.fn()
const warn = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => false,
warn,
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => dropState.mock.calls.length === 1)
expect(restart).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('distro not running'))
})
it('does nothing when the state was replaced or the manager is disposed', async () => {
const restart = vi.fn()
const probe = vi.fn(async () => true)
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => false,
restart,
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleRestart(state)
await new Promise((resolve) => setTimeout(resolve, 500))
expect(probe).not.toHaveBeenCalled()
expect(restart).not.toHaveBeenCalled()
})
it('does not drop or restart when the state was replaced mid-probe (probe false)', async () => {
let current = true
let resolveProbe: ((running: boolean) => void) | undefined
const probe = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
const restart = vi.fn()
const dropState = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => current,
restart,
dropState
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => probe.mock.calls.length === 1)
// A fresh ensure() replaces this state while the probe is in flight.
current = false
resolveProbe?.(false)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(dropState).not.toHaveBeenCalled()
expect(restart).not.toHaveBeenCalled()
})
it('does not restart when the state was replaced mid-probe (probe true)', async () => {
let current = true
let resolveProbe: ((running: boolean) => void) | undefined
const probe = vi.fn(() => new Promise<boolean>((resolve) => (resolveProbe = resolve)))
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: probe,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => current,
restart,
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleRestart(state)
await waitFor(() => probe.mock.calls.length === 1)
current = false
resolveProbe?.(true)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(restart).not.toHaveBeenCalled()
})
it('scheduleOneShotReinstall is a no-op once disposed', async () => {
const run = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
warn: vi.fn(),
isDisposed: () => true,
isCurrent: () => true,
restart: vi.fn(),
dropState: vi.fn()
})
const state = makeState()
recovery.scheduleOneShotReinstall(state, 10, run)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(run).not.toHaveBeenCalled()
expect(state.reinstallTimer).toBeUndefined()
})
it('clearTimers cancels both pending timers', async () => {
const restart = vi.fn()
const recovery = new WslRelayRecovery({
isDistroRunning: async () => true,
warn: vi.fn(),
isDisposed: () => false,
isCurrent: () => true,
restart,
dropState: vi.fn()
})
const state = makeState()
const reinstall = vi.fn()
recovery.scheduleRestart(state)
recovery.scheduleOneShotReinstall(state, 100, reinstall)
recovery.clearTimers(state)
await new Promise((resolve) => setTimeout(resolve, 500))
expect(restart).not.toHaveBeenCalled()
expect(reinstall).not.toHaveBeenCalled()
expect(state.restartTimer).toBeUndefined()
expect(state.reinstallTimer).toBeUndefined()
})
})
@@ -0,0 +1,81 @@
// Restart/reinstall timer policy for WSL hook relay states. Owns the two
// self-recovery timers so the manager's state machine stays declarative:
// WHEN to retry lives here, WHAT retrying means stays in the manager.
export type WslRelayRecoveryState = {
distro: string
cooldownUntil: number
restartTimer?: ReturnType<typeof setTimeout>
reinstallTimer?: ReturnType<typeof setTimeout>
}
export type WslRelayRecoveryIo = {
isDistroRunning: (distro: string) => Promise<boolean>
warn: (message: string) => void
isDisposed: () => boolean
/** True while this state object is still the one in the manager's map. */
isCurrent: (state: WslRelayRecoveryState) => boolean
restart: (distro: string) => void
dropState: (state: WslRelayRecoveryState) => void
}
export class WslRelayRecovery {
constructor(private io: WslRelayRecoveryIo) {}
/** Arms the restart timer for the state's cooldown. The probe gate matters:
* `wsl -d` BOOTS a stopped distro, so recovery must never resurrect a VM
* the user shut down — a stopped distro has no live agents anyway. */
scheduleRestart(state: WslRelayRecoveryState): void {
if (this.io.isDisposed() || state.restartTimer) {
return
}
const delayMs = Math.max(state.cooldownUntil - Date.now(), 0) + 250
state.restartTimer = setTimeout(() => {
state.restartTimer = undefined
void this.restartIfDistroRunning(state)
}, delayMs)
state.restartTimer.unref?.()
}
scheduleOneShotReinstall(state: WslRelayRecoveryState, delayMs: number, run: () => void): void {
if (this.io.isDisposed()) {
return
}
state.reinstallTimer = setTimeout(() => {
state.reinstallTimer = undefined
run()
}, delayMs)
state.reinstallTimer.unref?.()
}
clearTimers(state: WslRelayRecoveryState): void {
if (state.restartTimer) {
clearTimeout(state.restartTimer)
state.restartTimer = undefined
}
if (state.reinstallTimer) {
clearTimeout(state.reinstallTimer)
state.reinstallTimer = undefined
}
}
private async restartIfDistroRunning(state: WslRelayRecoveryState): Promise<void> {
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
return
}
const running = await this.io.isDistroRunning(state.distro)
// Why: a fresh ensure() may have replaced this state during the probe
// await — dropping/restarting here would then act on the replacement,
// orphaning its live relay child outside the manager's map.
if (this.io.isDisposed() || !this.io.isCurrent(state)) {
return
}
if (!running) {
this.io.warn(
`[agent-hooks] WSL hook relay (${state.distro}): distro not running (or probe failed); restart skipped (next WSL terminal re-ensures)`
)
this.io.dropState(state)
return
}
this.io.restart(state.distro)
}
}
@@ -0,0 +1,152 @@
// Sentinel wait for the WSL relay: consuming stdout until the READY sentinel,
// handing trailing/subsequent bytes to the mux in wire order via the microtask
// flush, and failing (kill + reject) on overflow, close, timeout, and NUL noise.
import { EventEmitter } from 'node:events'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol'
import {
MAX_STARTUP_BUFFER_BYTES,
waitForWslRelaySentinel,
type WslRelayStartupFailure
} from './wsl-hook-relay-sentinel'
type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType<typeof vi.fn> }
function fakeChild(): FakeChild {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: { write: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.stdin = { write: vi.fn(() => true) }
child.kill = vi.fn()
return child as unknown as FakeChild
}
function emitStdout(child: ChildProcessWithoutNullStreams, data: string | Buffer): void {
child.stdout.emit('data', Buffer.isBuffer(data) ? data : Buffer.from(data))
}
function emitStderr(child: ChildProcessWithoutNullStreams, data: Buffer): void {
child.stderr.emit('data', data)
}
function catchStartup(
promise: Promise<unknown>
): Promise<Error & { startup?: WslRelayStartupFailure }> {
return promise.then(
() => {
throw new Error('expected the sentinel wait to reject')
},
(err: Error & { startup?: WslRelayStartupFailure }) => err
)
}
afterEach(() => {
vi.useRealTimers()
})
describe('waitForWslRelaySentinel', () => {
it('resolves when the sentinel is split across two chunks', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
const sentinel = Buffer.from(RELAY_SENTINEL)
const mid = Math.floor(sentinel.length / 2)
emitStdout(child, sentinel.subarray(0, mid))
emitStdout(child, sentinel.subarray(mid))
const transport = await promise
expect(typeof transport.write).toBe('function')
expect(typeof transport.onData).toBe('function')
})
it('resolves past leading garbage and hands trailing bytes to onData', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(
child,
Buffer.concat([Buffer.from('junk noise '), Buffer.from(RELAY_SENTINEL), Buffer.from('TRAIL')])
)
const transport = await promise
const received: string[] = []
transport.onData((d) => received.push(d.toString('utf8')))
await Promise.resolve()
expect(received).toEqual(['TRAIL'])
})
it('delivers a pending trailing chunk before a later direct chunk, in wire order', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(child, Buffer.concat([Buffer.from(RELAY_SENTINEL), Buffer.from('FIRST')]))
const transport = await promise
const received: string[] = []
transport.onData((d) => received.push(d.toString('utf8')))
// A real stdout 'data' event is a macrotask; the queued pending flush is a
// microtask and must land ahead of any subsequent direct chunk.
await new Promise<void>((resolve) => {
setImmediate(() => {
emitStdout(child, 'SECOND')
resolve()
})
})
expect(received).toEqual(['FIRST', 'SECOND'])
})
it('defers the pending flush to a microtask so a caller can finish wiring', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(child, Buffer.concat([Buffer.from(RELAY_SENTINEL), Buffer.from('DEFER')]))
const transport = await promise
const received: string[] = []
transport.onData((d) => received.push(d.toString('utf8')))
// Nothing dispatched synchronously at registration — a second handler could
// still be added this tick before the first envelope flushes.
expect(received).toEqual([])
await Promise.resolve()
expect(received).toEqual(['DEFER'])
})
it('kills the child and rejects when startup output exceeds 64 KiB before the sentinel', async () => {
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
emitStdout(child, Buffer.alloc(MAX_STARTUP_BUFFER_BYTES + 1, 0x41))
const err = await settled
expect(err.message).toMatch(/64 KiB/)
expect(child.kill).toHaveBeenCalled()
})
it('rejects with an exit failure when the child closes before the sentinel', async () => {
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
child.emit('close', 7)
const err = await settled
expect(err.startup).toEqual({ kind: 'exit', code: 7, stderr: '' })
})
it('kills the child and rejects with a timeout after the sentinel deadline', async () => {
vi.useFakeTimers()
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
await vi.advanceTimersByTimeAsync(RELAY_SENTINEL_TIMEOUT_MS + 1)
const err = await settled
expect(child.kill).toHaveBeenCalled()
expect(err.startup?.kind).toBe('timeout')
})
it('strips NUL bytes from the stderr failure detail', async () => {
const child = fakeChild()
const settled = catchStartup(waitForWslRelaySentinel(child))
// wsl.exe without WSL_UTF8 emits UTF-16LE — "E_FAIL" as NUL-interleaved ASCII.
const nulLaden = Buffer.from('E_FAIL'.split('').flatMap((c) => [c.charCodeAt(0), 0]))
emitStderr(child, nulLaden)
child.emit('close', 1)
const err = await settled
expect(err.startup?.stderr).toBe('E_FAIL')
expect(err.message).toContain('E_FAIL')
expect(err.message).not.toContain(String.fromCharCode(0))
})
})
@@ -0,0 +1,159 @@
// Sentinel wait for the WSL agent-hook relay: consume the guest child's
// stdout until the READY sentinel, then hand the remaining stdio over as a
// MultiplexerTransport. WSL twin of the SSH deploy's waitForSentinel, over a
// ChildProcess instead of a ClientChannel.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol'
import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
export const MAX_STARTUP_BUFFER_BYTES = 64 * 1024
// Why: without WSL_UTF8, wsl.exe's own messages arrive UTF-16LE; NUL bytes
// in breadcrumbs and the catastrophic-failure matcher must not depend on the
// env var having taken effect (older wsl.exe ignores it).
export function decodeWslText(value: string): string {
return value.split(String.fromCharCode(0)).join('')
}
export type WslRelayStartupFailure = {
kind: 'exit' | 'timeout'
code: number | null
stderr: string
}
/** Wait for the relay's ready sentinel on the child's stdout, then hand the
* remaining stdio over as a MultiplexerTransport. WSL twin of the SSH
* deploy's waitForSentinel, over a ChildProcess instead of a ClientChannel. */
export function waitForWslRelaySentinel(
child: ChildProcessWithoutNullStreams
): Promise<MultiplexerTransport> {
return new Promise((resolve, reject) => {
let settled = false
let sentinelSeen = false
let stdoutBuffer: Buffer = Buffer.alloc(0)
let stderrOutput = ''
let exitCode: number | null = null
const sentinel = Buffer.from(RELAY_SENTINEL, 'utf8')
const dataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
// Why: post-sentinel chunks queue until the mux registers onData, then
// flush as a microtask — after the caller's synchronous wiring (the mux
// constructor registers onData before the manager can add notification
// handlers, so a synchronous flush could dispatch an early envelope to
// zero handlers) yet before any subsequent stdout IO event, so the frame
// decoder never sees chunks out of order. A setImmediate handoff would
// NOT preserve that: it is a macrotask the next 'data' event can beat.
const pendingChunks: Buffer[] = []
let closedNotified = false
const fail = (failure: WslRelayStartupFailure): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
reject(Object.assign(new Error(formatStartupFailure(failure)), { startup: failure }))
}
const timeout = setTimeout(() => {
child.kill()
fail({ kind: 'timeout', code: null, stderr: stderrOutput })
}, RELAY_SENTINEL_TIMEOUT_MS)
const notifyClosed = (): void => {
if (!closedNotified) {
closedNotified = true
for (const cb of closeCallbacks) {
cb()
}
}
}
const dispatch = (chunk: Buffer): void => {
if (dataCallbacks.length === 0) {
pendingChunks.push(chunk)
return
}
for (const cb of dataCallbacks) {
cb(chunk)
}
}
child.stderr.on('data', (d: Buffer) => {
stderrOutput = (stderrOutput + decodeWslText(d.toString('utf8'))).slice(
-MAX_STARTUP_BUFFER_BYTES
)
})
child.on('error', (err) =>
fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` })
)
child.on('exit', (code) => {
exitCode = code
})
child.on('close', (code) => {
if (sentinelSeen) {
notifyClosed()
return
}
fail({ kind: 'exit', code: code ?? exitCode, stderr: stderrOutput })
})
child.stdout.on('data', (chunk: Buffer) => {
if (sentinelSeen) {
dispatch(chunk)
return
}
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const idx = stdoutBuffer.indexOf(sentinel)
if (idx === -1) {
// Why: pre-sentinel stdout is untrusted startup noise; cap it so a
// broken guest cannot grow memory until the timeout fires.
if (stdoutBuffer.length > MAX_STARTUP_BUFFER_BYTES) {
child.kill()
fail({ kind: 'exit', code: null, stderr: 'startup output exceeded 64 KiB' })
}
return
}
sentinelSeen = true
settled = true
clearTimeout(timeout)
const trailing = stdoutBuffer.subarray(idx + sentinel.length)
if (trailing.length > 0) {
pendingChunks.push(trailing)
}
const transport: MultiplexerTransport = {
write: (data) => {
try {
child.stdin.write(data)
} catch {
// Channel already closing — mux close handling takes over.
}
},
onData: (cb) => {
dataCallbacks.push(cb)
if (dataCallbacks.length === 1 && pendingChunks.length > 0) {
queueMicrotask(() => {
for (const pending of pendingChunks.splice(0)) {
for (const dataCb of dataCallbacks) {
dataCb(pending)
}
}
})
}
},
onClose: (cb) => closeCallbacks.push(cb),
close: () => child.kill()
}
resolve(transport)
})
})
}
function formatStartupFailure(failure: WslRelayStartupFailure): string {
const detail = failure.stderr.trim()
if (failure.kind === 'timeout') {
return `WSL hook relay did not become ready within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s${detail ? `: ${detail}` : ''}`
}
return `WSL hook relay exited (code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}`
}
@@ -44,7 +44,9 @@ export function remoteSessionSources(
),
// The remote host owns the transcript disk, so the local readdir in the
// Claude parser is skipped; the walked listing supplies the sibling
// subagent counts instead.
// subagent counts instead. Partitioning also prunes the subagent
// transcripts themselves, which would otherwise list as phantom
// top-level sessions carrying the parent's sessionId.
collectSubagentSiblingCounts: true
},
source(
@@ -201,6 +201,50 @@ describe('scanRemoteAiVaultSessions', () => {
})
})
it('excludes Claude subagent transcripts from remote scans', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/claude-session.jsonl',
jsonLines([
{
sessionId: 'claude-session',
timestamp: '2026-07-04T04:00:00.000Z',
type: 'user',
message: { content: [{ type: 'text', text: 'Spawn a Task' }] }
}
]),
40
)
provider.addFile(
'/home/ada/.claude/projects/repo/claude-session/subagents/agent-abc123.jsonl',
jsonLines([
{
sessionId: 'claude-session',
isSidechain: true,
agentId: 'abc123',
timestamp: '2026-07-04T04:01:00.000Z',
type: 'user',
message: { content: [{ type: 'text', text: 'Subagent task prompt' }] }
}
]),
41
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
// Only the parent session surfaces; the subagent transcript would carry
// the parent's sessionId and list as a phantom top-level row.
expect(result.sessions.map((session) => session.filePath)).toEqual([
'/home/ada/.claude/projects/repo/claude-session.jsonl'
])
})
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
@@ -239,7 +283,7 @@ describe('scanRemoteAiVaultSessions', () => {
})
})
it('ignores remote subagent siblings for Claude sessions with real turns', async () => {
it('counts remote subagent siblings for Claude sessions with real turns', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/live-session.jsonl',
@@ -267,10 +311,12 @@ describe('scanRemoteAiVaultSessions', () => {
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
// The walk listing supplies the count for every session (the row badge),
// not only zero-turn recoverable ones.
expect(result.sessions[0]).toMatchObject({
sessionId: 'live-session',
messageCount: 1,
subagentTranscriptCount: 0
subagentTranscriptCount: 1
})
})
+4 -3
View File
@@ -209,10 +209,11 @@ async function parseRemoteSessionCandidate(
return null
}
const session = await candidate.source.parse(candidate.file, read.content, context)
// Mirror the local zero-turn-only rule: sibling subagent transcripts are
// recoverable signal only when the parent conversation persisted no turns.
// Mirror the local rule: every session carries its sibling subagent
// transcript count (row badge; recoverable signal at zero turns). The
// walk listing supplies it — the parser can't readdir a remote disk.
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
if (session && session.messageCount === 0 && subagentTranscriptCount > 0) {
if (session && subagentTranscriptCount > 0) {
return { ...session, subagentTranscriptCount }
}
return session
@@ -141,6 +141,7 @@ function session(
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume ${sessionId}`
resumeCommand: `codex resume ${sessionId}`,
subagent: null
}
}
+11 -1
View File
@@ -74,7 +74,17 @@ const aiVaultListResultSchema = z.object({
// fields) parseable; they simply report no recoverable-empty sessions.
queuedMessageCount: z.number().default(0),
subagentTranscriptCount: z.number().default(0),
resumeCommand: z.string()
resumeCommand: z.string(),
// The default keeps remote hosts running an older build (no subagent
// field) parseable; scanned top-level sessions carry null anyway.
subagent: z
.object({
parentSessionId: z.string(),
agentType: z.string().nullable(),
status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable()
})
.nullable()
.default(null)
})
),
issues: z.array(
@@ -121,7 +121,8 @@ export function finalizeSession(
cwd: accumulator.cwd,
platform,
codexHome: options.codexHome
})
}),
subagent: null
}
}
@@ -0,0 +1,61 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('scanAiVaultSessions Claude subagent pruning', () => {
it('prunes subagents/ transcripts instead of listing them as sessions', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-prune-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const subagentsDir = join(roots.claudeProjectsDir, 'project', 'claude-session', 'subagents')
await mkdir(subagentsDir, { recursive: true })
await writeFile(
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'claude-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/repo/app',
message: { role: 'user', content: 'Spawn a Task' }
}
])
)
await writeFile(
join(subagentsDir, 'agent-abc123.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'claude-session',
isSidechain: true,
agentId: 'abc123',
timestamp: '2026-05-01T10:01:00.000Z',
cwd: '/repo/app',
message: { role: 'user', content: 'Subagent task prompt' }
}
])
)
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
expect(result.issues).toEqual([])
// Only the parent session surfaces; the subagent transcript is not a row,
// but it is still counted for the row's "N subagents" affordance.
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
sessionId: 'claude-session',
subagentTranscriptCount: 1
})
})
})
@@ -0,0 +1,396 @@
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listClaudeSubagentSessions } from './session-scanner-claude-subagents'
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
let tempRoots: string[] = []
async function writeJsonlFile(filePath: string, records: unknown[]): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`)
}
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
async function writeSubagentTranscript(args: {
subagentsDir: string
agentId: string
taskPrompt: string
timestamp: string
}): Promise<void> {
await writeJsonlFile(join(args.subagentsDir, `agent-${args.agentId}.jsonl`), [
{
type: 'user',
sessionId: 'parent-session',
isSidechain: true,
agentId: args.agentId,
timestamp: args.timestamp,
cwd: '/tmp/claude',
message: { role: 'user', content: args.taskPrompt }
}
])
}
describe('listClaudeSubagentSessions', () => {
it('lists subagents titled by the spawn description, newest first, linked to the parent', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-list-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
await writeJsonlFile(parentFilePath, [
{
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Parent prompt' }
}
])
await writeSubagentTranscript({
subagentsDir,
agentId: 'older',
taskPrompt: 'Task prompt for the older subagent',
timestamp: '2026-07-05T10:01:00.000Z'
})
await writeFile(
join(subagentsDir, 'agent-older.meta.json'),
JSON.stringify({
agentType: 'Explore',
description: 'Map the scanner internals',
toolUseId: 'toolu_older'
})
)
await writeSubagentTranscript({
subagentsDir,
agentId: 'newer',
taskPrompt: 'Task prompt for the newer subagent',
timestamp: '2026-07-05T10:02:00.000Z'
})
await writeFile(
join(subagentsDir, 'agent-newer.meta.json'),
JSON.stringify({
agentType: 'general-purpose',
description: 'Check latest news',
toolUseId: 'toolu_newer'
})
)
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.title)).toEqual([
'Check latest news',
'Map the scanner internals'
])
// No task notifications in the parent and freshly written transcripts:
// both count as still running.
expect(result.sessions.map((session) => session.subagent)).toEqual([
{ parentSessionId: 'parent-session', agentType: 'general-purpose', status: 'running' },
{ parentSessionId: 'parent-session', agentType: 'Explore', status: 'running' }
])
expect(result.sessions.map((session) => session.messageCount)).toEqual([1, 1])
})
it('falls back to the Task prompt title when the meta sidecar is missing or corrupt', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-meta-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
await writeSubagentTranscript({
subagentsDir,
agentId: 'nometa',
taskPrompt: 'Sidecarless task prompt',
timestamp: '2026-07-05T10:01:00.000Z'
})
await writeSubagentTranscript({
subagentsDir,
agentId: 'badmeta',
taskPrompt: 'Corrupt sidecar task prompt',
timestamp: '2026-07-05T10:02:00.000Z'
})
await writeFile(join(subagentsDir, 'agent-badmeta.meta.json'), '{not json')
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.title)).toEqual([
'Corrupt sidecar task prompt',
'Sidecarless task prompt'
])
expect(result.sessions.map((session) => session.subagent?.agentType)).toEqual([null, null])
})
it('resolves statuses from parent task notifications, falling back to transcript recency', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-status-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
await writeJsonlFile(parentFilePath, [
{
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Parent prompt' }
},
{
// Interim notification: superseded by the terminal one below.
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:30.000Z',
message: {
role: 'user',
content:
'<task-notification>\n<task-id>finished</task-id>\n<status>running</status>\n</task-notification>'
}
},
{
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:01:00.000Z',
message: {
role: 'user',
content:
'<task-notification>\n<task-id>finished</task-id>\n<status>completed</status>\n</task-notification>'
}
},
{
// queue-operation records carry the notification as a plain string.
type: 'queue-operation',
operation: 'enqueue',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:02:00.000Z',
content:
'<task-notification>\n<task-id>crashed</task-id>\n<status>failed</status>\n</task-notification>'
},
{
// Synchronous Tasks finish with a toolUseResult record, no notification.
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:03:00.000Z',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_sync', content: 'done' }]
},
toolUseResult: { status: 'completed', agentId: 'syncdone', agentType: 'Explore' }
}
])
for (const agentId of ['finished', 'crashed', 'active', 'stale', 'syncdone']) {
await writeSubagentTranscript({
subagentsDir,
agentId,
taskPrompt: `Task for ${agentId}`,
timestamp: '2026-07-05T10:00:10.000Z'
})
}
// A transcript silent for an hour with no terminal notification has no
// trustworthy status.
const staleTime = new Date(Date.now() - 60 * 60_000)
await utimes(join(subagentsDir, 'agent-stale.jsonl'), staleTime, staleTime)
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result.issues).toEqual([])
const statusByTitle = Object.fromEntries(
result.sessions.map((session) => [session.title, session.subagent?.status ?? null])
)
expect(statusByTitle).toEqual({
'Task for finished': 'completed',
'Task for crashed': 'failed',
'Task for active': 'running',
'Task for stale': null,
'Task for syncdone': 'completed'
})
expect(await countSubagentTranscripts(parentFilePath)).toBe(5)
})
it('reads a terminal status whose <status> sits past the title truncation limit', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-longnote-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
// A real notification carries <tool-use-id>/<output-file> before <status>,
// pushing <status> well past the 96-char title cap; delivered as a user
// message it must still be read untruncated so the terminal status wins
// over the transcript-recency fallback.
const notification =
'<task-notification>\n<task-id>bigtask</task-id>\n' +
'<tool-use-id>toolu_01HB57dwUE6hQVK6Mh8jVFHA</tool-use-id>\n' +
'<output-file>/private/tmp/claude-501/-Users-me-orca-workspaces-project/' +
'parent-session/tasks/bigtask.output</output-file>\n' +
'<status>completed</status>\n<summary>Agent finished</summary>\n</task-notification>'
expect(notification.indexOf('<status>')).toBeGreaterThan(96)
await writeJsonlFile(parentFilePath, [
{
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Parent prompt' }
},
{
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:01:00.000Z',
message: { role: 'user', content: notification }
}
])
// Freshly written, so a dropped status would fall back to 'running' — the
// assertion below only holds when the notification is parsed untruncated.
await writeSubagentTranscript({
subagentsDir,
agentId: 'bigtask',
taskPrompt: 'Task for bigtask',
timestamp: '2026-07-05T10:00:10.000Z'
})
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.subagent?.status)).toEqual(['completed'])
})
it('ignores a quoted notification in a user prompt so it cannot overwrite a real status', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-quoted-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
await writeJsonlFile(parentFilePath, [
{
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:00.000Z',
message: {
role: 'user',
content:
'<task-notification>\n<task-id>quoted</task-id>\n<status>completed</status>\n</task-notification>'
}
},
{
// A user prompt that merely quotes an earlier notification must not
// supersede the real terminal status recorded above.
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:01:00.000Z',
message: {
role: 'user',
content:
'Re-run: <task-notification>\n<task-id>quoted</task-id>\n<status>failed</status>\n</task-notification>'
}
}
])
const staleTime = new Date(Date.now() - 60 * 60_000)
await writeSubagentTranscript({
subagentsDir,
agentId: 'quoted',
taskPrompt: 'Task for quoted',
timestamp: '2026-07-05T10:00:10.000Z'
})
await utimes(join(subagentsDir, 'agent-quoted.jsonl'), staleTime, staleTime)
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.subagent?.status)).toEqual(['completed'])
})
it('reads a sync-Task toolUseResult status even when its report quotes a notification', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-turmarker-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
await writeJsonlFile(parentFilePath, [
{
// A sync-Task completion: the terminal status lives in toolUseResult,
// but the subagent's own report text quotes <task-notification>, so the
// raw-line prefilter must not misroute it into the notification branch.
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:03:00.000Z',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_report',
content: 'The subagent explained how <task-notification> records work.'
}
]
},
toolUseResult: { status: 'completed', agentId: 'reporter', agentType: 'Explore' }
}
])
// Stale, so a dropped status would fall back to null, not 'completed'.
const staleTime = new Date(Date.now() - 60 * 60_000)
await writeSubagentTranscript({
subagentsDir,
agentId: 'reporter',
taskPrompt: 'Task for reporter',
timestamp: '2026-07-05T10:00:10.000Z'
})
await utimes(join(subagentsDir, 'agent-reporter.jsonl'), staleTime, staleTime)
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.subagent?.status)).toEqual(['completed'])
})
it('links to the parent derived from its file path even when the transcript has no sessionId', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-parent-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
const subagentsDir = join(root, 'project', 'parent-session', 'subagents')
// A transcript with no sessionId record: the parser falls back to the
// subagent's own filename-derived id, so parentSessionId must not use it.
await writeJsonlFile(join(subagentsDir, 'agent-nosession.jsonl'), [
{
type: 'user',
isSidechain: true,
agentId: 'nosession',
timestamp: '2026-07-05T10:01:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Orphan subagent prompt' }
}
])
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.subagent?.parentSessionId)).toEqual([
'parent-session'
])
})
it('returns an empty list when the session never spawned subagents', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-none-'))
tempRoots.push(root)
const parentFilePath = join(root, 'project', 'parent-session.jsonl')
await writeJsonlFile(parentFilePath, [
{
type: 'user',
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Parent prompt' }
}
])
const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' })
expect(result).toEqual({ sessions: [], issues: [] })
})
})
@@ -0,0 +1,281 @@
import { createReadStream } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type {
AiVaultScanIssue,
AiVaultSession,
AiVaultSubagentListResult,
AiVaultSubagentRunStatus
} from '../../shared/ai-vault-types'
import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import {
isSubagentTranscriptFileName,
subagentTranscriptsDirFor,
SUBAGENT_TRANSCRIPT_PREFIX
} from './session-scanner-subagent-transcripts'
import {
asRecord,
errorMessage,
extractString,
normalizeTitleText,
parseJsonObject
} from './session-scanner-values'
// A subagent writing nothing for this long without a terminal notification is
// treated as no longer running (its status stays unknown rather than stale).
const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000
// Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY):
// opening every subagent read stream at once stalls over SSH/WSL UNC paths.
const SUBAGENT_PARSE_CONCURRENCY = 8
const TASK_NOTIFICATION_MARKER = '<task-notification>'
const TOOL_USE_RESULT_MARKER = '"toolUseResult"'
// A sync-Task toolUseResult sets a status only when it carries an agentId. Tool
// output records (Read/Bash) also carry "toolUseResult" and are the largest lines
// in a transcript, so gating on this second marker keeps the status pass from
// JSON-parsing ~all of the file's bytes on every on-demand fetch.
const TOOL_USE_RESULT_AGENT_ID_MARKER = '"agentId"'
const TASK_ID_PATTERN = /<task-id>([^<]+)<\/task-id>/
const TASK_STATUS_PATTERN = /<status>([a-z_]+)<\/status>/
// Statuses reported by parent-transcript <task-notification> records
// (background Tasks) and toolUseResult records (synchronous Tasks).
const TERMINAL_TASK_STATUSES: Record<string, AiVaultSubagentRunStatus> = {
completed: 'completed',
failed: 'failed',
killed: 'stopped',
stopped: 'stopped'
}
type ClaudeSubagentMeta = {
description: string | null
agentType: string | null
}
/**
* List the Task subagent transcripts of one Claude session, on demand. The
* main scan prunes `subagents/` subtrees for speed, so this is the only path
* that reads them — and only when the user expands a session's details.
*/
export async function listClaudeSubagentSessions(args: {
parentFilePath: string
platform?: NodeJS.Platform
now?: number
}): Promise<AiVaultSubagentListResult> {
const platform = args.platform ?? process.platform
const now = args.now ?? Date.now()
const issues: AiVaultScanIssue[] = []
const subagentsDir = subagentTranscriptsDirFor(args.parentFilePath)
let entries
try {
entries = await readdir(subagentsDir, { withFileTypes: true })
} catch {
return { sessions: [], issues }
}
const transcriptNames = entries
.filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile()))
.map((entry) => entry.name)
if (transcriptNames.length === 0) {
return { sessions: [], issues }
}
// One pass over the parent transcript resolves every subagent's status.
const statusByAgentId = await collectSubagentTaskStatuses(args.parentFilePath)
// Why: the layout fixes the parent's sessionId (<parent>.jsonl -> subagents/);
// deriving it here avoids a subagent transcript with no sessionId records
// linking to its own filename-derived id instead of the parent.
const parentSessionId = sessionIdFromFileName(args.parentFilePath)
const parsed: (AiVaultSession | null)[] = []
for (let index = 0; index < transcriptNames.length; index += SUBAGENT_PARSE_CONCURRENCY) {
const batch = transcriptNames.slice(index, index + SUBAGENT_PARSE_CONCURRENCY)
const batchResults = await Promise.all(
batch.map((name) =>
parseSubagentTranscript({
filePath: join(subagentsDir, name),
agentId: subagentIdFromFileName(name),
parentSessionId,
statusByAgentId,
now,
platform,
issues
})
)
)
parsed.push(...batchResults)
}
return {
sessions: parsed
.filter((session): session is AiVaultSession => session !== null)
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left)),
issues
}
}
function subagentIdFromFileName(name: string): string {
return basename(name, extname(name)).slice(SUBAGENT_TRANSCRIPT_PREFIX.length)
}
async function parseSubagentTranscript(args: {
filePath: string
agentId: string
parentSessionId: string
statusByAgentId: ReadonlyMap<string, string>
now: number
platform: NodeJS.Platform
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession | null> {
try {
const fileStat = await stat(args.filePath)
const session = await parseClaudeSessionFile(
{
path: args.filePath,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString()
},
args.platform
)
if (!session) {
return null
}
const meta = await readSubagentMeta(args.filePath)
return {
...session,
// Why: the spawn description is the name the main agent gave this Task;
// it beats the transcript-derived fallback (the raw Task prompt).
title: meta.description ?? session.title,
subagent: {
// The parent's sessionId is derived from its file path, not the
// subagent transcript, so it survives transcripts with no sessionId.
parentSessionId: args.parentSessionId,
agentType: meta.agentType,
status: resolveSubagentStatus({
reportedStatus: args.statusByAgentId.get(args.agentId),
mtimeMs: fileStat.mtimeMs,
now: args.now
})
}
}
} catch (err) {
args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) })
return null
}
}
function resolveSubagentStatus(args: {
reportedStatus: string | undefined
mtimeMs: number
now: number
}): AiVaultSubagentRunStatus | null {
const terminal = args.reportedStatus ? TERMINAL_TASK_STATUSES[args.reportedStatus] : undefined
if (terminal) {
return terminal
}
// No terminal notification yet: a recently-written transcript is running;
// a stale one has no trustworthy status (e.g. the parent was interrupted).
return args.now - args.mtimeMs <= SUBAGENT_RUNNING_RECENCY_MS ? 'running' : null
}
// The parent transcript reports subagent completion in two shapes: sync Tasks
// finish with a toolUseResult record ({ agentId, status }); background Tasks
// launch with status 'async_launched' and finish with a <task-notification>
// whose <task-id> is the agentId. Last record wins, so interim statuses (e.g.
// 'async_launched', notification 'running') are superseded by terminal ones.
async function collectSubagentTaskStatuses(parentFilePath: string): Promise<Map<string, string>> {
const statuses = new Map<string, string>()
try {
const lines = createInterface({
input: createReadStream(parentFilePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const hasNotification = line.includes(TASK_NOTIFICATION_MARKER)
const hasTaskResult =
line.includes(TOOL_USE_RESULT_MARKER) && line.includes(TOOL_USE_RESULT_AGENT_ID_MARKER)
if (!hasNotification && !hasTaskResult) {
continue
}
const record = parseJsonObject(line)
if (!record) {
continue
}
// Only records whose text IS the notification set a status; a user prompt
// (or a sync-Task toolUseResult report) that merely quotes one also trips
// the raw-line prefilter, so it must not consume the record — fall through
// to the toolUseResult branch below rather than dropping a real status.
// Accepted limitation: a user message that BEGINS with a verbatim
// notification is indistinguishable from a real delivery (records carry no
// provenance marker), so it can set a bogus status. The impact is a wrong
// dot on a view-only row, so we don't gate on user-type markers here (that
// would risk dropping genuine harness-delivered statuses).
if (hasNotification) {
const text = taskNotificationText(record)
if (text.startsWith(TASK_NOTIFICATION_MARKER)) {
const taskId = TASK_ID_PATTERN.exec(text)?.[1]?.trim()
const status = TASK_STATUS_PATTERN.exec(text)?.[1]
if (taskId && status) {
statuses.set(taskId, status)
}
continue
}
}
const result = asRecord(record.toolUseResult)
const agentId = extractString(result?.agentId)
const status = extractString(result?.status)
if (agentId && status) {
statuses.set(agentId, status)
}
}
} catch {
// A missing/unreadable parent transcript degrades to recency-only status.
}
return statuses
}
// The <status> marker follows <tool-use-id>/<output-file> lines in real
// notifications, so it sits well past the 96-char title cap — the notification
// text must be read untruncated (unlike titles/previews). Both delivery shapes
// appear: queue-operation records carry it as top-level `content`; user-message
// records under `message.content` (a string, or text content blocks).
function taskNotificationText(record: Record<string, unknown>): string {
const direct = extractString(record.content)
if (direct) {
return direct
}
const content = asRecord(record.message)?.content
if (typeof content === 'string') {
return content.trim()
}
if (Array.isArray(content)) {
return content.map(taskNotificationBlockText).filter(Boolean).join(' ').trim()
}
return ''
}
function taskNotificationBlockText(block: unknown): string {
if (typeof block === 'string') {
return block
}
const record = asRecord(block)
return extractString(record?.text) ?? extractString(record?.content) ?? ''
}
// Claude writes an `agent-<id>.meta.json` sidecar for every subagent transcript,
// carrying the Task tool's spawn `description` and its resolved `agentType`.
async function readSubagentMeta(transcriptPath: string): Promise<ClaudeSubagentMeta> {
const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json`
try {
const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown)
return {
description: normalizeTitleText(extractString(record?.description) ?? ''),
agentType: extractString(record?.agentType)
}
} catch {
// The sidecar is optional; the transcript still yields a usable title.
return { description: null, agentType: null }
}
}
@@ -85,4 +85,55 @@ describe('parseCodexSessionFile', () => {
expect(session?.totalTokens).toBe(150)
})
it('extracts the model from turn context, latest turn winning', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-model-'))
tempRoots.push(root)
const sessionPath = join(root, 'sessions', '2026', '07', '05', 'rollout-model.jsonl')
await mkdir(dirname(sessionPath), { recursive: true })
await writeFile(
sessionPath,
jsonLines([
{
timestamp: '2026-07-05T10:00:00.000Z',
type: 'session_meta',
payload: { id: 'model-session', cwd: '/repo/app' }
},
{
timestamp: '2026-07-05T10:00:01.000Z',
type: 'turn_context',
payload: { cwd: '/repo/app', model: 'gpt-5.1' }
},
{
timestamp: '2026-07-05T10:00:02.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Switch the model' }]
}
},
{
// A /model switch mid-session writes a later turn_context.
timestamp: '2026-07-05T10:00:03.000Z',
type: 'turn_context',
payload: { cwd: '/repo/app', model: 'gpt-5.5' }
}
])
)
const sessionStat = await stat(sessionPath)
const session = await parseCodexSessionFile(
{
path: sessionPath,
mtimeMs: sessionStat.mtimeMs,
modifiedAt: sessionStat.mtime.toISOString()
},
'darwin',
root
)
expect(session?.model).toBe('gpt-5.5')
})
})
@@ -70,6 +70,8 @@ export async function walkSessionFiles(
options: {
extensions: Set<string>
filePredicate?: (path: string) => boolean
// Return false to skip descending into a directory (matched by its name),
// so pruned subtrees are never stat'd or parsed.
directoryPredicate?: (name: string) => boolean
}
): Promise<string[]> {
@@ -153,13 +153,16 @@ export async function finalizeClaudeSessionParseState(
// session name (ai-title) should outrank the raw first prompt when present.
snapshot.accumulator.fallbackTitle =
snapshot.generatedTitle ?? snapshot.firstUserTitle ?? snapshot.metaTitle
// Only a zero-turn transcript needs its sibling subagent transcripts counted;
// for normal sessions the extra directory read is skipped. The sibling dir
// Every session's sibling subagent transcripts are counted (one readdir):
// the row UI shows the count without expanding details, and for zero-turn
// transcripts it doubles as the recoverable-content signal. The sibling dir
// lives on the host that owns the transcript, so content fetched from a
// remote (SSH) host must not readdir this machine's disk.
// remote (SSH) host must not readdir this machine's disk. Runtime hosts scan
// their own local disk (their host id is stamped after parse), so they are
// already covered by the undefined-executionHostId branch.
const ownsTranscriptDisk =
!options.executionHostId || options.executionHostId === LOCAL_EXECUTION_HOST_ID
if (snapshot.accumulator.messageCount === 0 && ownsTranscriptDisk) {
if (ownsTranscriptDisk) {
snapshot.accumulator.subagentTranscriptCount = await countSubagentTranscripts(
snapshot.accumulator.filePath
)
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -99,7 +99,7 @@ describe('recoverable-but-empty Claude sessions', () => {
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(false)
})
it('does not count subagent transcripts for a session that has real turns', async () => {
it('counts subagent transcripts for a session that has real turns without flagging it recoverable', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nonempty-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
@@ -127,8 +127,10 @@ describe('recoverable-but-empty Claude sessions', () => {
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
expect(session?.messageCount).toBe(1)
// Skipped for sessions with content: the directory read only runs at zero turns.
expect(session?.subagentTranscriptCount).toBe(0)
// Counted for every local session (the row's "N subagents" affordance),
// but a session with real turns is never surfaced as recoverable-empty.
expect(session?.subagentTranscriptCount).toBe(1)
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(false)
})
it('counts queued messages net of remove and dequeue operations', async () => {
@@ -242,4 +244,18 @@ describe('countSubagentTranscripts', () => {
expect(await countSubagentTranscripts(transcriptPath)).toBe(2)
})
it('excludes non-agent .jsonl files and directories named like transcripts', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-sub-predicate-'))
tempRoots.push(root)
const transcriptPath = join(root, 'session.jsonl')
const subagentsDir = join(root, 'session', 'subagents')
await writeJsonlFile(join(subagentsDir, 'agent-a.jsonl'), [{ type: 'user' }])
// A stray sibling artifact and a directory would inflate the row badge past
// what the on-demand lister shows; both must fail the shared predicate.
await writeFile(join(subagentsDir, 'notes.jsonl'), '{}')
await mkdir(join(subagentsDir, 'agent-x.jsonl'))
expect(await countSubagentTranscripts(transcriptPath)).toBe(1)
})
})
@@ -2,6 +2,7 @@ import { homedir } from 'node:os'
import { basename, join } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts'
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
@@ -39,6 +40,18 @@ const DEVIN_TRANSCRIPTS_DIR = join(
'transcripts'
)
// The local host and each WSL distro's `~/.claude/projects`. Callers reading
// Claude session files by path use these roots to reject arbitrary paths.
export function claudeProjectsRootDirs(args: {
claudeProjectsDir?: string
wslHomeDirs?: readonly string[]
}): string[] {
return [
args.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR,
...(args.wslHomeDirs ?? []).map((homeDir) => join(homeDir, '.claude', 'projects'))
]
}
export async function discoverAiVaultSessionSources(args: {
options: AiVaultScanOptions
limitPerAgent: number
@@ -77,10 +90,10 @@ function claudeDiscoveries(
limit: number,
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
return [
options.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR,
...wslHomeDirs.map((homeDir) => join(homeDir, '.claude', 'projects'))
].map((rootDir) =>
return claudeProjectsRootDirs({
claudeProjectsDir: options.claudeProjectsDir,
wslHomeDirs
}).map((rootDir) =>
discoverFiles({
rootDir,
limit,
@@ -89,8 +102,9 @@ function claudeDiscoveries(
extensions: ['.jsonl'],
// Why: Task subagent transcripts under `<session>/subagents/` share the parent
// sessionId and aren't independently resumable, so they'd just duplicate the
// parent as untitled rows; prune the subtree instead of indexing it.
directoryPredicate: (name) => name !== 'subagents'
// parent as untitled rows; prune the subtree and read them on demand under
// their parent instead.
directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME
})
)
}
@@ -1,13 +1,32 @@
import { readdir } from 'node:fs/promises'
import { basename, dirname, extname, join } from 'node:path'
// Exported so discovery can prune these subtrees using the same literal that
// locates them here — the pruning comment and behavior can't drift.
export const SUBAGENT_DIR_NAME = 'subagents'
// Claude names every Task subagent transcript `agent-<id>.jsonl`. The row-badge
// count, the recoverable-empty signal, and the on-demand lister all key off this
// one predicate so the "N subagents" badge can never disagree with the expanded
// list (a stray non-transcript `.jsonl` or a dir named `x.jsonl` would otherwise
// inflate the count).
export const SUBAGENT_TRANSCRIPT_PREFIX = 'agent-'
export function isSubagentTranscriptFileName(name: string, isFile: boolean): boolean {
return (
isFile &&
name.startsWith(SUBAGENT_TRANSCRIPT_PREFIX) &&
extname(name).toLowerCase() === '.jsonl'
)
}
// Claude writes subagent transcripts to a sibling directory named after the
// parent transcript file (…/<enc>/<uuid>.jsonl → …/<enc>/<uuid>/subagents/).
// These survive intact even when the parent conversation persisted zero turns,
// so they are the recoverable signal that keeps such a session from being hidden.
export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
const stem = basename(transcriptFilePath, extname(transcriptFilePath))
return join(dirname(transcriptFilePath), stem, 'subagents')
return join(dirname(transcriptFilePath), stem, SUBAGENT_DIR_NAME)
}
/**
@@ -17,19 +36,20 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
* transcripts and are excluded.
*/
export async function countSubagentTranscripts(transcriptFilePath: string): Promise<number> {
let entries: string[]
let entries
try {
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath))
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true })
} catch {
return 0
}
return entries.filter((name) => name.endsWith('.jsonl')).length
return entries.filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile())).length
}
// Direct child of a subagents dir: `<parent>/<uuid>/subagents/<file>.jsonl`.
// Greedy prefix means nested subagent trees attribute to their nearest parent,
// matching the local direct-children-only readdir semantics.
const SUBAGENT_DIRECT_CHILD_PATTERN = /^(.*)[\\/]subagents[\\/][^\\/]+\.jsonl$/i
// Direct child of a subagents dir: `<parent>/<uuid>/subagents/agent-<id>.jsonl`.
// The `agent-` prefix mirrors the local count/list predicate so remote badges
// can't over-count. Greedy prefix means nested subagent trees attribute to their
// nearest parent, matching the local direct-children-only readdir semantics.
const SUBAGENT_DIRECT_CHILD_PATTERN = /^(.*)[\\/]subagents[\\/]agent-[^\\/]+\.jsonl$/i
const SUBAGENT_SUBTREE_PATTERN = /[\\/]subagents[\\/]/i
/**
@@ -33,6 +33,7 @@ import {
import { app } from 'electron'
import type { CodexManagedAccount } from '../../shared/types'
import type { Store } from '../persistence'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { writeFileAtomically } from './fs-utils'
import {
getOrcaManagedCodexHomePath,
@@ -657,9 +658,7 @@ export class CodexRuntimeHomeService {
private getWslRuntimeHomePath(distro: string): string | null {
const home = getWslHome(distro)
return home
? this.joinWslPath(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
: null
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
}
private safeReadBackActiveWslAccountBeforeRestart(
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({
execFile: execFileMock
}))
import { _internals } from './codex-wsl-hook-install-plan'
const originalPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
beforeEach(() => {
execFileMock.mockReset()
_internals.resetWslCanonicalPathCache()
})
afterEach(() => {
setPlatform(originalPlatform)
})
describe('canonicalizeWslLinuxPath', () => {
it('returns the path unchanged off Windows without spawning wsl.exe', () => {
setPlatform('linux')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice')
expect(execFileMock).not.toHaveBeenCalled()
})
it('never blocks: returns null and schedules an async resolution on first call', () => {
setPlatform('win32')
const result = _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
expect(result).toBeNull()
expect(execFileMock).toHaveBeenCalledTimes(1)
const [file, args] = execFileMock.mock.calls[0]
expect(file).toBe('wsl.exe')
expect(args).toEqual(['-d', 'Ubuntu', '--', 'readlink', '-f', '--', '/home/alias'])
})
it('caches the resolved canonical path and stops spawning wsl.exe', () => {
setPlatform('win32')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
callback(null, '/home/alice\n')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice')
expect(execFileMock).toHaveBeenCalledTimes(1)
})
it('does not spawn a second subprocess while one is in flight', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
expect(execFileMock).toHaveBeenCalledTimes(1)
})
it('retries after a failed resolution rather than caching the failure', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
callback(new Error('wsl unreachable'), '')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
expect(execFileMock).toHaveBeenCalledTimes(2)
})
it('ignores non-absolute readlink output', () => {
setPlatform('win32')
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void
callback(null, 'readlink: missing operand\n')
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
})
})
+54 -14
View File
@@ -1,4 +1,4 @@
import { execFileSync } from 'node:child_process'
import { execFile } from 'node:child_process'
import { win32 as pathWin32 } from 'node:path'
import { parseWslUncPath } from '../../shared/wsl-paths'
@@ -29,24 +29,56 @@ function toDefaultWslLinuxPath(windowsPath: string): string {
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}`
}
const WSL_CANONICALIZE_TIMEOUT_MS = 5000
// Why: `readlink -f` over wsl.exe stalls up to the timeout on a cold or wedged
// distro. Running it synchronously on the Electron main process froze the UI on
// every Codex WSL launch, so resolve it off-thread and cache the stable result.
const canonicalWslPathCache = new Map<string, string>()
const inFlightWslCanonicalizations = new Set<string>()
function wslCanonicalizeCacheKey(distro: string, linuxPath: string): string {
return `${distro}\x00${linuxPath}`
}
function scheduleWslLinuxPathCanonicalization(distro: string, linuxPath: string): void {
const key = wslCanonicalizeCacheKey(distro, linuxPath)
// Why: cap the wedged-WSL blast radius to a single background subprocess per
// (distro, path); the cache holds the answer once one resolution succeeds.
if (canonicalWslPathCache.has(key) || inFlightWslCanonicalizations.has(key)) {
return
}
inFlightWslCanonicalizations.add(key)
execFile(
'wsl.exe',
['-d', distro, '--', 'readlink', '-f', '--', linuxPath],
{ encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true },
(error, stdout) => {
inFlightWslCanonicalizations.delete(key)
if (error) {
return
}
const canonicalPath = stdout.trim()
if (canonicalPath.startsWith('/')) {
canonicalWslPathCache.set(key, canonicalPath)
}
}
)
}
function canonicalizeWslLinuxPath(distro: string, linuxPath: string): string | null {
if (process.platform !== 'win32') {
return linuxPath
}
try {
const canonicalPath = execFileSync(
'wsl.exe',
['-d', distro, '--', 'readlink', '-f', '--', linuxPath],
{
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
}
).trim()
return canonicalPath.startsWith('/') ? canonicalPath : null
} catch {
return null
const cached = canonicalWslPathCache.get(wslCanonicalizeCacheKey(distro, linuxPath))
if (cached) {
return cached
}
// Why: no canonical path resolved yet — start the off-thread resolution and
// let the caller use the logical path for now (identical unless HOME crosses a
// symlink). The next launch reads the cached canonical path.
scheduleWslLinuxPathCanonicalization(distro, linuxPath)
return null
}
export function createCodexWslRuntimeHookInstallPlan(
@@ -85,3 +117,11 @@ export function createCodexWslRuntimeHookInstallPlan(
trustConfigPath: `${linuxRuntimeHome}/hooks.json`
}
}
export const _internals = {
canonicalizeWslLinuxPath,
resetWslCanonicalPathCache(): void {
canonicalWslPathCache.clear()
inFlightWslCanonicalizations.clear()
}
}
+30 -4
View File
@@ -1184,9 +1184,25 @@ export class CodexHookService {
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json`
const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml`
async installRemote(
sftp: SFTPWrapper,
remoteHome: string,
options?: {
/** Explicit CODEX_HOME dir (flat layout: hooks.json/config.toml at its
* root). WSL sessions read Orca's managed runtime home, not ~/.codex —
* installing to the default location leaves those sessions hookless. */
codexHomeDir?: string
/** Skip the trust write when config.toml doesn't exist yet. The WSL
* runtime home's config.toml is seeded only-if-absent by the launch
* path; creating it here first would silently cancel that seed. A
* later (idempotent) reinstall upserts trust once the seed lands. */
deferTrustUntilConfigToml?: boolean
}
): Promise<AgentHookInstallStatus> {
const codexHomeBase =
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
const remoteConfigPath = `${codexHomeBase}/hooks.json`
const remoteTomlPath = `${codexHomeBase}/config.toml`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
@@ -1245,7 +1261,17 @@ export class CodexHookService {
// Preserve non-Orca top-level metadata while replacing the hooks tree.
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
try {
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
const existingTomlRaw = await readTextFileRemote(sftp, remoteTomlPath)
if (existingTomlRaw === null && options?.deferTrustUntilConfigToml === true) {
return {
agent: 'codex',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: 'Trust entries deferred until config.toml is seeded by the launch path'
}
}
const existingToml = existingTomlRaw ?? ''
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
if (updatedToml !== existingToml) {
await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml)
+42 -10
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
branchHasUpstream,
probeBranchUpstream,
renameCurrentBranch,
resolveUniqueBranchName,
type GitExec
@@ -12,8 +12,8 @@ const noUpstreamError = new Error(
' git push --set-upstream origin feature'
)
describe('branchHasUpstream', () => {
it('is true when @{u} resolves to a tracking ref', async () => {
describe('probeBranchUpstream', () => {
it('reports has-upstream when @{u} resolves to a tracking ref', async () => {
const exec: GitExec = vi.fn(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n', stderr: '' }
@@ -23,10 +23,10 @@ describe('branchHasUpstream', () => {
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
expect(await branchHasUpstream(exec)).toBe(true)
expect(await probeBranchUpstream(exec)).toEqual({ outcome: 'has-upstream' })
})
it('is false when there is no upstream', async () => {
it('reports no-upstream when there is no upstream', async () => {
const exec: GitExec = vi.fn(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n', stderr: '' }
@@ -39,10 +39,10 @@ describe('branchHasUpstream', () => {
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
expect(await branchHasUpstream(exec)).toBe(false)
expect(await probeBranchUpstream(exec)).toEqual({ outcome: 'no-upstream' })
})
it('is true when a same-name origin tracking ref exists without configured upstream', async () => {
it('reports has-upstream when a same-name origin tracking ref exists without configured upstream', async () => {
const exec: GitExec = vi.fn(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n', stderr: '' }
@@ -55,12 +55,44 @@ describe('branchHasUpstream', () => {
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
expect(await branchHasUpstream(exec)).toBe(true)
expect(await probeBranchUpstream(exec)).toEqual({ outcome: 'has-upstream' })
})
it('is conservatively true on an unexpected failure', async () => {
it('reports probe-failed on an unexpected failure', async () => {
const exec: GitExec = vi.fn().mockRejectedValue(new Error('fatal: not a git repository'))
expect(await branchHasUpstream(exec)).toBe(true)
expect(await probeBranchUpstream(exec)).toEqual({
outcome: 'probe-failed',
message: 'fatal: not a git repository'
})
})
it('scrubs credential-bearing URLs from the probe-failed message', async () => {
// The message surfaces on the worktree card, so an embedded remote URL
// must not leak a token or password into the UI.
const exec: GitExec = vi
.fn()
.mockRejectedValue(
new Error('fatal: unable to access https://user:hunter2@example.com/repo.git/: timed out')
)
expect(await probeBranchUpstream(exec)).toEqual({
outcome: 'probe-failed',
message: 'fatal: unable to access https://example.com/repo.git/: timed out'
})
})
it('reports probe-failed, not has-upstream, for localized git diagnostics (issue #7808)', async () => {
// A gettext-enabled git under de_DE translates even the `fatal:` prefix.
const exec: GitExec = vi.fn(async (args: string[]) => {
if (args[0] === 'symbolic-ref') {
return { stdout: 'feature\n', stderr: '' }
}
throw new Error(
'Command failed: git rev-parse --abbrev-ref HEAD@{u}\n' +
"Schwerwiegend: Kein Upstream-Branch für Branch 'feature' konfiguriert."
)
})
const probe = await probeBranchUpstream(exec)
expect(probe.outcome).toBe('probe-failed')
})
})
+20 -11
View File
@@ -1,4 +1,4 @@
import { isNoUpstreamError } from '../../shared/git-remote-error'
import { isNoUpstreamError, stripCredentialsFromMessage } from '../../shared/git-remote-error'
import {
resolveEffectiveGitUpstream,
type GitCommandRunner
@@ -11,22 +11,31 @@ import {
*/
export type GitExec = GitCommandRunner
export type BranchUpstreamProbe =
| { outcome: 'has-upstream' }
| { outcome: 'no-upstream' }
| { outcome: 'probe-failed'; message: string }
/**
* True when the branch has a configured upstream — i.e. it has been pushed or
* is tracking a remote. Auto-rename refuses to touch such a branch because
* `git branch -m` would orphan the remote branch and break any open PR.
* Whether the branch has an upstream — i.e. it has been pushed or is tracking
* a remote. Auto-rename refuses to touch such a branch because `git branch -m`
* would orphan the remote branch and break any open PR.
*/
export async function branchHasUpstream(exec: GitExec): Promise<boolean> {
export async function probeBranchUpstream(exec: GitExec): Promise<BranchUpstreamProbe> {
try {
return (await resolveEffectiveGitUpstream(exec)) !== null
const upstream = await resolveEffectiveGitUpstream(exec)
return { outcome: upstream !== null ? 'has-upstream' : 'no-upstream' }
} catch (error) {
if (isNoUpstreamError(error)) {
return false
return { outcome: 'no-upstream' }
}
// Why: an unexpected failure is not proof either way — report it as its own
// outcome so callers skip the rename but stay retryable (issue #7808).
// The message surfaces in the UI, so scrub credential-bearing remote URLs.
return {
outcome: 'probe-failed',
message: stripCredentialsFromMessage(error instanceof Error ? error.message : String(error))
}
// Why: an unexpected failure (detached HEAD, corruption, transport error)
// is not proof there's no upstream. Stay conservative and report "has
// upstream" so the caller skips the rename rather than risk a published branch.
return true
}
}
+24 -1
View File
@@ -9,7 +9,8 @@ import {
nonInteractiveGitEnv,
parseRetryAfterMs,
promptGuardGitEnv,
redirectPortedHostnameToEnv
redirectPortedHostnameToEnv,
untranslatedGitOutputEnv
} from './runner'
// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
@@ -218,3 +219,25 @@ describe('nonInteractiveGitEnv credential-interactivity disable (STA-1292)', ()
expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
})
})
describe('git env forces untranslated diagnostics (issue #7808)', () => {
it('overrides an inherited non-English locale so stderr parsers keep working', () => {
// A gettext-enabled git under de_DE translates even the `fatal:` prefix,
// breaking isNoUpstreamError and every other stderr phrase match.
const env = promptGuardGitEnv({ PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' })
expect(env.LC_ALL).toBe('en_US.UTF-8')
expect(env.LANG).toBe('en_US.UTF-8')
})
it('pins LANGUAGE, which outranks LC_ALL in gettext lookups', () => {
const env = untranslatedGitOutputEnv({ PATH: '/usr/bin', LANGUAGE: 'de:en' })
expect(env.LANGUAGE).toBe('en')
expect(env.LC_ALL).toBe('en_US.UTF-8')
})
it('applies to nonInteractiveGitEnv as well', () => {
const env = nonInteractiveGitEnv({ PATH: '/usr/bin', LANG: 'fr_FR.UTF-8' })
expect(env.LC_ALL).toBe('en_US.UTF-8')
expect(env.LANGUAGE).toBe('en')
})
})
+30 -4
View File
@@ -35,9 +35,17 @@ import {
escapeWslShCommandForWindows,
quotePosixShell
} from '../../shared/wsl-login-shell-command'
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
// ─── Core resolution ────────────────────────────────────────────────
// `LANGUAGE=en LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8` — env-assignment prefix for
// WSL-routed git, where spawn env cannot cross the wsl.exe boundary. Values are
// shell-safe unquoted (alnum, dot, dash, underscore only).
const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV)
.map(([key, value]) => `${key}=${value}`)
.join(' ')
type ResolvedCommand = {
binary: string
args: string[]
@@ -202,6 +210,10 @@ function resolveCommand(
}
const translatedArgs = translateArgsForWsl(args)
// Why: env set on wsl.exe stays on the Windows side (WSLENV forwards only
// named vars), so the untranslated-output locale must ride the command
// string for git stderr parsers to work inside the distro (issue #7808).
const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : ''
const escapedCommand = quotePosixShell(command)
// Why: shell-escape each argument to prevent word splitting / glob expansion
// inside the bash -c string. Single quotes are safe for all chars except
@@ -214,8 +226,8 @@ function resolveCommand(
// doesn't need a particular cwd for global calls like `api rate_limit`.
const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null)
const shellCmd = linuxCwd
? `cd ${quotePosixShell(linuxCwd)} && ${escapedCommand} ${escapedArgs.join(' ')}`
: `${escapedCommand} ${escapedArgs.join(' ')}`
? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
: `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
if (options.useWslLoginShell) {
return {
@@ -560,10 +572,21 @@ export function appendGitConfigEnv(
return next
}
/**
* Pin Orca-spawned git to untranslated English output so stderr/progress
* parsers keep working under any user locale (issue #7808; see
* UNTRANSLATED_GIT_OUTPUT_ENV for the full rationale). Terminal git is
* untouched. Injected by every git runner in this module; WSL-routed spawns
* get the same values via GIT_OUTPUT_LOCALE_SHELL_PREFIX instead.
*/
export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
export function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return appendGitConfigEnv(
{
...env,
...untranslatedGitOutputEnv(env),
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
@@ -870,7 +893,8 @@ export async function gitExecFileAsyncBuffer(
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
maxBuffer: options.maxBuffer
maxBuffer: options.maxBuffer,
env: untranslatedGitOutputEnv()
})) as { stdout: Buffer }
return { stdout }
}
@@ -1051,6 +1075,7 @@ export function gitExecFileSync(
return execFileSync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
env: untranslatedGitOutputEnv(),
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS
}) as string
@@ -1076,6 +1101,7 @@ export function gitSpawn(
const spawnStartedAt = performance.now()
const child = spawn(resolved.binary, resolved.args, {
...spawnOptions,
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
cwd: resolved.cwd
})
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getGrokAccountStatus } from './status'
import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth'
vi.mock('../rate-limits/grok-auth', () => ({
isGrokAccessTokenFresh: vi.fn(),
readGrokAuthSession: vi.fn()
}))
describe('getGrokAccountStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isGrokAccessTokenFresh).mockReturnValue(true)
})
it('reports unsigned status when the Grok auth file is missing', () => {
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
expect(getGrokAccountStatus()).toEqual({
signedIn: false,
email: null,
teamId: null,
tokenFresh: false,
error: null
})
})
it('reports auth read errors without exposing token fields', () => {
vi.mocked(readGrokAuthSession).mockReturnValue({
status: 'error',
error: 'Grok auth file is invalid'
})
expect(getGrokAccountStatus()).toEqual({
signedIn: false,
email: null,
teamId: null,
tokenFresh: false,
error: 'Grok auth file is invalid'
})
})
it('returns non-secret signed-in metadata and freshness', () => {
vi.mocked(readGrokAuthSession).mockReturnValue({
status: 'ok',
session: {
accessToken: 'secret-token',
email: 'dev@example.com',
teamId: 'team-1',
userId: 'user-1',
expiresAtMs: null,
oidcClientId: 'client-1'
}
})
vi.mocked(isGrokAccessTokenFresh).mockReturnValue(false)
const status = getGrokAccountStatus()
expect(status).toEqual({
signedIn: true,
email: 'dev@example.com',
teamId: 'team-1',
tokenFresh: false,
error: null
})
expect(JSON.stringify(status)).not.toContain('secret-token')
expect(JSON.stringify(status)).not.toContain('client-1')
})
})
+32
View File
@@ -0,0 +1,32 @@
import type { GrokAccountStatus } from '../../shared/rate-limit-types'
import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth'
export function getGrokAccountStatus(): GrokAccountStatus {
const readResult = readGrokAuthSession()
if (readResult.status === 'missing') {
return {
signedIn: false,
email: null,
teamId: null,
tokenFresh: false,
error: null
}
}
if (readResult.status === 'error') {
return {
signedIn: false,
email: null,
teamId: null,
tokenFresh: false,
error: readResult.error
}
}
const session = readResult.session
return {
signedIn: true,
email: session.email,
teamId: session.teamId,
tokenFresh: isGrokAccessTokenFresh(session),
error: null
}
}
+4
View File
@@ -132,6 +132,7 @@ import {
} from './claude-accounts/live-pty-gate'
import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager'
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
import { moveWorktree } from './git/worktree'
@@ -2257,6 +2258,9 @@ app.on('will-quit', (e) => {
automations?.stop()
setUnreadDockBadgeCount(0)
agentHookServer.stop()
// Why: cancels relay restart/reinstall timers and kills wsl.exe children
// deterministically instead of relying on stdio-pipe teardown.
wslHookRelayManager.disposeAll()
stats?.flush()
// Why: agent-browser daemon processes would otherwise linger after Orca quits,
// holding ports and leaving stale session state on disk.
+86 -1
View File
@@ -1,3 +1,5 @@
import { homedir } from 'node:os'
import { join, sep } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import type { IFilesystemProvider } from '../providers/types'
@@ -6,6 +8,7 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
const mocks = vi.hoisted(() => ({
scanAiVaultSessions: vi.fn(),
scanRemoteAiVaultSessions: vi.fn(),
listClaudeSubagentSessions: vi.fn(),
scanRuntimeAiVaultSessions: vi.fn(),
getAiVaultWslHomeDirs: vi.fn(),
getSshFilesystemProvider: vi.fn(),
@@ -26,6 +29,10 @@ vi.mock('../ai-vault/remote-session-scanner', () => ({
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
}))
vi.mock('../ai-vault/session-scanner-claude-subagents', () => ({
listClaudeSubagentSessions: mocks.listClaudeSubagentSessions
}))
vi.mock('../wsl', () => ({
getWslHomeAsync: mocks.getAiVaultWslHomeDirs,
listWslDistrosAsync: vi.fn().mockResolvedValue([])
@@ -53,6 +60,7 @@ beforeEach(() => {
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
result([session('ssh:dev-box', 'remote-session')])
)
mocks.listClaudeSubagentSessions.mockResolvedValue({ sessions: [], issues: [] })
mocks.scanRuntimeAiVaultSessions.mockResolvedValue(
result([session('runtime:remote-server', 'runtime-session')])
)
@@ -198,6 +206,82 @@ describe('listAiVaultSessions host routing', () => {
})
})
describe('listAiVaultSubagentSessions gating', () => {
const claudeRoot = join(homedir(), '.claude', 'projects')
it('lists subagents for a local Claude session inside the projects root', async () => {
const parentFilePath = join(claudeRoot, 'proj', 'sess.jsonl')
await _internals.listAiVaultSubagentSessions({
agent: 'claude',
parentFilePath,
executionHostId: 'local'
})
expect(mocks.listClaudeSubagentSessions).toHaveBeenCalledWith({ parentFilePath })
})
it('returns empty for a remote Claude session without reading the filesystem', async () => {
const result = await _internals.listAiVaultSubagentSessions({
agent: 'claude',
parentFilePath: join(claudeRoot, 'proj', 'sess.jsonl'),
executionHostId: 'ssh:dev-box'
})
expect(result).toEqual({ sessions: [], issues: [] })
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
})
it('rejects a path outside the Claude projects root', async () => {
const result = await _internals.listAiVaultSubagentSessions({
agent: 'claude',
parentFilePath: '/etc/secrets/subagents',
executionHostId: 'local'
})
expect(result).toEqual({ sessions: [], issues: [] })
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
})
it('rejects a dot-segment traversal out of the Claude projects root', async () => {
// Built with sep (not join) so the `..` segments survive into the arg.
const traversal = [claudeRoot, '..', '..', '..', 'etc', 'passwd.jsonl'].join(sep)
const result = await _internals.listAiVaultSubagentSessions({
agent: 'claude',
parentFilePath: traversal,
executionHostId: 'local'
})
expect(result).toEqual({ sessions: [], issues: [] })
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
})
it('resolves empty for malformed IPC payloads instead of throwing', async () => {
const missing = await _internals.listAiVaultSubagentSessions(undefined)
const badPath = await _internals.listAiVaultSubagentSessions({
agent: 'claude',
parentFilePath: 42 as unknown as string,
executionHostId: 'local'
})
expect(missing).toEqual({ sessions: [], issues: [] })
expect(badPath).toEqual({ sessions: [], issues: [] })
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
})
it('returns empty for a non-Claude agent', async () => {
const result = await _internals.listAiVaultSubagentSessions({
agent: 'codex',
parentFilePath: join(claudeRoot, 'proj', 'sess.jsonl'),
executionHostId: 'local'
})
expect(result).toEqual({ sessions: [], issues: [] })
expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled()
})
})
function hostInfo(targetId: string) {
return {
targetId,
@@ -239,6 +323,7 @@ function session(
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume ${sessionId}`
resumeCommand: `codex resume ${sessionId}`,
subagent: null
}
}
+50 -2
View File
@@ -1,10 +1,18 @@
import { app, ipcMain } from 'electron'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents'
import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery'
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import type {
AiVaultListArgs,
AiVaultListResult,
AiVaultSubagentListArgs,
AiVaultSubagentListResult
} from '../../shared/ai-vault-types'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostScope,
@@ -263,6 +271,11 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
listAiVaultSessions(args)
)
ipcMain.handle(
'aiVault:listSubagentSessions',
(_event, args?: AiVaultSubagentListArgs): Promise<AiVaultSubagentListResult> =>
listAiVaultSubagentSessions(args)
)
// DOM focus/visibility events don't fire in the renderer on macOS app
// activation, so refresh-on-refocus needs this main-process signal.
app.on('browser-window-focus', (_event, window) => {
@@ -272,6 +285,40 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
})
}
// Provider-gated: only Claude materializes Task subagent transcripts as
// sibling files today; other agents resolve to an empty list.
async function listAiVaultSubagentSessions(
args?: AiVaultSubagentListArgs
): Promise<AiVaultSubagentListResult> {
// IPC payloads are untyped at runtime; malformed input resolves empty like
// every other rejected input instead of throwing.
if (
!args ||
args.agent !== 'claude' ||
typeof args.parentFilePath !== 'string' ||
!args.parentFilePath.trim()
) {
return { sessions: [], issues: [] }
}
// Why: subagent transcripts are read from the local filesystem. The UI
// skips remote sessions (their transcripts live on the remote host); return
// empty defensively rather than reading local paths for a remote session.
const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID
if (executionHostId !== LOCAL_EXECUTION_HOST_ID) {
return { sessions: [], issues: [] }
}
// Why: the path is renderer-supplied; only list files under a known Claude
// projects root so a crafted path can't readdir/preview arbitrary dirs.
// resolve() collapses `..` segments first — isPathInsideOrEqual compares
// textually and would otherwise pass `<root>/../../etc/x.jsonl`.
const parentFilePath = resolve(args.parentFilePath)
const roots = claudeProjectsRootDirs({ wslHomeDirs: await getAiVaultWslHomeDirs() })
if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) {
return { sessions: [], issues: [] }
}
return listClaudeSubagentSessions({ parentFilePath })
}
function resetAiVaultCacheForTests(): void {
cachedList = null
inflightList = null
@@ -281,6 +328,7 @@ function resetAiVaultCacheForTests(): void {
export const _internals = {
listAiVaultSessions,
listAiVaultSubagentSessions,
resetAiVaultCacheForTests
}
+6
View File
@@ -0,0 +1,6 @@
import { ipcMain } from 'electron'
import { getGrokAccountStatus } from '../grok-accounts/status'
export function registerGrokAccountHandlers(): void {
ipcMain.handle('grokAccounts:getStatus', () => getGrokAccountStatus())
}
+21
View File
@@ -31,6 +31,7 @@ import {
getFirstCommandToken
} from '../../shared/command-token-scanner'
import { agentHookServer } from '../agent-hooks/server'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind'
@@ -508,6 +509,9 @@ export type BuildPtyHostEnvOptions = {
launchCommand?: string
shellPath?: string
isWsl?: boolean
/** Distro for WSL spawns (null = Windows default distro). Drives the WSL
* hook relay ensure + guest endpoint repoint; only read when isWsl. */
wslDistro?: string | null
agentStatusHooksEnabled: boolean
networkProxySettings?: NetworkProxySettings
}
@@ -865,6 +869,19 @@ export function buildPtyHostEnv(
}
if (opts.agentStatusHooksEnabled) {
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
if (opts.isWsl === true) {
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace. Ensure
// the guest-resident relay for this distro (covers fresh spawns and
// post-restart reattach re-spawns), and once the relay has reported the
// guest home, point restart re-coordination at the relay-written
// guest-side endpoint file instead of the /p-translated Windows one.
const distro = opts.wslDistro ?? null
wslHookRelayManager.ensureForDistro(distro)
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
if (guestEndpoint) {
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
}
}
}
// Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root. Keep
@@ -1327,6 +1344,7 @@ export function registerPtyHandlers(
launchCommand: ctx?.command,
shellPath: ctx?.shellPath,
isWsl: ctx?.isWsl,
wslDistro: ctx?.wslDistro ?? null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
@@ -2187,6 +2205,7 @@ export function registerPtyHandlers(
launchCommand: args.command,
shellPath: daemonShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
wslDistro: codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
@@ -2960,6 +2979,8 @@ export function registerPtyHandlers(
launchCommand: args.command,
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
wslDistro:
codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
+18 -2
View File
@@ -16,11 +16,17 @@ import { registerRateLimitHandlers } from './rate-limits'
import type { RateLimitService } from '../rate-limits/service'
import type { RateLimitState } from '../../shared/rate-limit-types'
function makeService(): { service: RateLimitService; refresh: ReturnType<typeof vi.fn> } {
function makeService(): {
service: RateLimitService
refresh: ReturnType<typeof vi.fn>
refreshGrok: ReturnType<typeof vi.fn>
} {
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
const refreshGrok = vi.fn(() => Promise.resolve({} as RateLimitState))
const service = {
getState: vi.fn(() => ({}) as RateLimitState),
refresh,
refreshGrok,
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
consumeCodexRateLimitResetCredit: vi.fn(() =>
@@ -30,7 +36,7 @@ function makeService(): { service: RateLimitService; refresh: ReturnType<typeof
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
}
return { service: service as unknown as RateLimitService, refresh }
return { service: service as unknown as RateLimitService, refresh, refreshGrok }
}
describe('registerRateLimitHandlers', () => {
@@ -53,5 +59,15 @@ describe('registerRateLimitHandlers', () => {
expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refreshGrok')).toBe(true)
})
it('registers a refreshGrok channel that delegates to refreshGrok()', async () => {
const { service, refreshGrok } = makeService()
registerRateLimitHandlers(service)
const handler = ipcState.handleHandlers.get('rateLimits:refreshGrok')
expect(handler).toBeDefined()
await handler!({})
expect(refreshGrok).toHaveBeenCalledTimes(1)
})
})
+1
View File
@@ -24,4 +24,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
rateLimits.fetchInactiveCodexAccountsOnOpen()
)
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok())
}
@@ -37,6 +37,7 @@ const {
registerAgentTrustHandlersMock,
registerClaudeAccountHandlersMock,
registerMiniMaxCredentialsHandlersMock,
registerGrokAccountHandlersMock,
registerClipboardHandlersMock,
setTrustedClipboardRendererWebContentsIdMock,
registerUpdaterHandlersMock,
@@ -97,6 +98,7 @@ const {
registerAgentTrustHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
registerGrokAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn(),
@@ -309,6 +311,10 @@ vi.mock('./minimax-credentials', () => ({
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
}))
vi.mock('./grok-accounts', () => ({
registerGrokAccountHandlers: registerGrokAccountHandlersMock
}))
vi.mock('../window/attach-main-window-services', () => ({
registerUpdaterHandlers: registerUpdaterHandlersMock
}))
@@ -464,6 +470,7 @@ describe('registerCoreHandlers', () => {
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerLinearHandlersMock).toHaveBeenCalled()
+2
View File
@@ -56,6 +56,7 @@ import { registerAgentHookHandlers } from './agent-hooks'
import { registerAgentTrustHandlers } from './agent-trust'
import { registerClaudeAccountHandlers } from './claude-accounts'
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
import { registerGrokAccountHandlers } from './grok-accounts'
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
import {
registerClipboardHandlers,
@@ -125,6 +126,7 @@ export function registerCoreHandlers(
registerAgentTrustHandlers()
registerClaudeAccountHandlers(claudeAccounts)
registerMiniMaxCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
registerGitLabHandlers(store)
+18
View File
@@ -306,6 +306,24 @@ describe('registerSettingsHandlers', () => {
)
})
it('normalizes terminal line height updates before persistence', async () => {
store.getSettings.mockReturnValue({ terminalLineHeight: 1 })
store.updateSettings.mockReturnValue({ terminalLineHeight: 1 })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
await handler(settingsInvokeEvent, { terminalLineHeight: 0.85 })
expect(store.updateSettings).toHaveBeenCalledWith(
{ terminalLineHeight: 1 },
{ notifyListeners: true, originWebContentsId: 1 }
)
})
it('normalizes custom terminal themes from renderer settings IPC', async () => {
store.getSettings.mockReturnValue({ terminalCustomThemes: [] })
store.updateSettings.mockReturnValue({ terminalCustomThemes: [] })
+4
View File
@@ -18,6 +18,7 @@ import { normalizeUiLanguage } from '../../shared/ui-language'
import { applyAppIcon } from '../app-icon'
import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes'
import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-scrollback-policy'
import { normalizeTerminalLineHeight } from '../../shared/terminal-line-height-settings'
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
@@ -95,6 +96,9 @@ export function registerSettingsHandlers(
args.terminalScrollbackRows
)
}
if ('terminalLineHeight' in args) {
sanitizedArgs.terminalLineHeight = normalizeTerminalLineHeight(args.terminalLineHeight)
}
if ('uiLanguage' in args) {
sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage)
}
+28
View File
@@ -476,6 +476,34 @@ describe('scanOpenCodeUsageDatabases', () => {
expect(sessionOne?.eventCount).toBe(1)
})
it('reuses a fully-duplicate backup instead of reparsing it when the live db changes', async () => {
// The backup only holds stale copies of sessions the canonical db already
// owns, so it owns nothing. When the live db changes it must not be
// demoted back into the parse set — there is no claim to reclaim.
const canonicalPath = writeSessionTotalsDb('opencode.db', [['session-1', 1000]])
writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]])
const first = await scanOpenCodeUsageDatabases([], [])
const firstBackup = first.processedDatabases.find((database) =>
database.path.endsWith('opencode-backup.db')
)
expect(firstBackup?.ownedSessionIds).toEqual([])
const db = new Database(canonicalPath)
insertSessionTotalsRow(db, 'session-2', 200)
db.close()
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
const secondBackup = second.processedDatabases.find((database) =>
database.path.endsWith('opencode-backup.db')
)
// A reused cache entry is the same object; a reparse would produce a new one.
expect(secondBackup).toBe(firstBackup)
expect(
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
).toBe(1200)
})
it('lets the live database reclaim sessions after a sticky backup-only claim', async () => {
// First scan only has the backup (e.g. live db temporarily missing), so it
// owns session-1 at the stale snapshot. When opencode.db reappears with
+9 -4
View File
@@ -935,11 +935,16 @@ export async function scanOpenCodeUsageDatabases(
// Why: a sticky backup claim from a scan where opencode.db was missing would
// otherwise freeze a still-growing session at the backup snapshot when the
// live db reappears. Reparse lower-priority siblings whenever a higher-
// priority path is being re-evaluated so the live db can reclaim sessions
// without double-counting cached backup aggregates.
// live db reappears. Reparse a lower-priority sibling only when it still owns
// sessions a higher-priority path could reclaim; a sibling that owns nothing
// (the common case once the live db has claimed every shared session) has no
// claim to give back, so reparsing it every time the live db changes is pure
// work.
const demotedReusePaths: string[] = []
for (const dbPath of reusedByPath.keys()) {
for (const [dbPath, reused] of reusedByPath) {
if ((reused.ownedSessionIds?.length ?? 0) === 0) {
continue
}
const higherPriorityParsing = pathsToParse.some(
(candidate) => compareOpenCodeClaimPriority(candidate, dbPath) < 0
)
+14
View File
@@ -535,6 +535,20 @@ describe('Store', () => {
expect(settings.notifications.suppressWhenFocused).toBe(true)
})
it('repairs a persisted terminal line height outside xterm bounds', async () => {
const persisted = getDefaultPersistedState(testState.dir)
writeDataFile({
...persisted,
settings: { ...persisted.settings, terminalLineHeight: 0.85 }
})
const store = await createStore()
expect(store.getSettings().terminalLineHeight).toBe(1)
store.flush()
expect((readDataFile() as PersistedState).settings.terminalLineHeight).toBe(1)
})
it('returns default UI state when no data file exists', async () => {
const store = await createStore()
const ui = store.getUI()
+11
View File
@@ -203,6 +203,7 @@ import {
normalizeTuiAgentEnvRecord
} from '../shared/tui-agent-launch-defaults'
import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings'
import { normalizeTerminalLineHeight } from '../shared/terminal-line-height-settings'
import { normalizeUiLanguage } from '../shared/ui-language'
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
import { persistedUIValuesEqual } from '../shared/persisted-ui-equality'
@@ -2987,6 +2988,15 @@ export class Store {
parsed.settings
)
const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings)
const migratedTerminalLineHeight = normalizeTerminalLineHeight(
parsed.settings?.terminalLineHeight
)
if (
parsed.settings?.terminalLineHeight !== undefined &&
parsed.settings.terminalLineHeight !== migratedTerminalLineHeight
) {
this.loadNeedsSave = true
}
const rawTaskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders: parsed.settings?.visibleTaskProviders,
defaultTaskSource: parsed.settings?.defaultTaskSource
@@ -3119,6 +3129,7 @@ export class Store {
primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults,
...migratedAutoRenameBranchFromWork,
...migratedTerminalCursorStyle,
terminalLineHeight: migratedTerminalLineHeight,
...migratedTerminalTuiScrollSensitivity.settings,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
+16
View File
@@ -1,3 +1,19 @@
/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync
* with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which
* builds the UNC twin of this path. */
export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
] as const
export function wslCodexRuntimeHomeForGuestHome(guestHome: string): string {
const home = guestHome.endsWith('/') ? guestHome.slice(0, -1) : guestHome
return `${home}/${WSL_CODEX_RUNTIME_HOME_SEGMENTS.join('/')}`
}
export function isHostCodexHomeForWsl(value: string | undefined): boolean {
const trimmed = value?.trim()
if (!trimmed) {
+21
View File
@@ -47,4 +47,25 @@ describe('addOrcaWslInteropEnv', () => {
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
})
it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => {
const windowsEnv: Record<string, string> = {
ORCA_AGENT_HOOK_ENDPOINT: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\agent-hooks\\endpoint.cmd'
}
addOrcaWslInteropEnv(windowsEnv)
expect(windowsEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
const guestEnv: Record<string, string> = {
ORCA_AGENT_HOOK_ENDPOINT: '/home/jin/.orca-wsl/agent-hooks/port-4567/endpoint.env'
}
addOrcaWslInteropEnv(guestEnv)
expect(guestEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/u')
expect(guestEnv.WSLENV).not.toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
})
it('marks the WSL hook relay version for import on relay spawn envs', () => {
const env: Record<string, string> = { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('ORCA_WSL_HOOK_RELAY_VERSION/u')
})
})
+7 -1
View File
@@ -16,6 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void {
export function addOrcaWslInteropEnv(env: Record<string, string>): void {
const entries = parseWslenvEntries(env.WSLENV)
// Why: the endpoint is a Windows path (/p-translated so the guest reads it
// via /mnt/c) until the WSL hook relay reports the guest home — then it is
// already a guest-side POSIX path and must cross untranslated.
const endpointFlag = env.ORCA_AGENT_HOOK_ENDPOINT?.startsWith('/') ? 'u' : 'p'
// Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start.
const passthroughEntries = [
'ORCA_TERMINAL_HANDLE/u',
@@ -28,7 +32,9 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
'ORCA_AGENT_HOOK_TOKEN/u',
'ORCA_AGENT_HOOK_ENV/u',
'ORCA_AGENT_HOOK_VERSION/u',
'ORCA_AGENT_HOOK_ENDPOINT/p',
`ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`,
'ORCA_WSL_HOOK_RELAY_VERSION/u',
'ORCA_WSL_HOOK_INSTANCE/u',
'ORCA_OMP_SOURCE_AGENT_DIR/p',
'ORCA_OMP_STATUS_EXTENSION/p'
]
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('readGrokAuthSession', () => {
afterEach(() => {
vi.resetModules()
vi.doUnmock('node:fs')
})
it('redacts filesystem paths from auth read failures', async () => {
vi.doMock('node:fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => {
throw new Error(
'EACCES: permission denied, open /Users/brennanbenson/private/.grok/auth.json'
)
})
}))
const { readGrokAuthSession } = await import('./grok-auth')
expect(readGrokAuthSession()).toEqual({
status: 'error',
error: 'Unable to read Grok auth file'
})
})
it('treats a token-less auth file as signed out, not an error', async () => {
vi.doMock('node:fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => JSON.stringify({ 'https://auth.x.ai::client': { user_id: 'u1' } }))
}))
const { readGrokAuthSession } = await import('./grok-auth')
expect(readGrokAuthSession()).toEqual({ status: 'missing' })
})
it('reports malformed auth JSON without parser details', async () => {
vi.doMock('node:fs', () => ({
existsSync: vi.fn(() => true),
readFileSync: vi.fn(() => '{')
}))
const { readGrokAuthSession } = await import('./grok-auth')
expect(readGrokAuthSession()).toEqual({
status: 'error',
error: 'Grok auth file is invalid'
})
})
})
+116
View File
@@ -0,0 +1,116 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
// Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses.
export function getGrokHome(): string {
return process.env.GROK_HOME?.trim() || join(homedir(), '.grok')
}
export function getGrokAuthPath(): string {
return join(getGrokHome(), 'auth.json')
}
export type GrokAuthSession = {
accessToken: string
userId: string | null
email: string | null
teamId: string | null
expiresAtMs: number | null
oidcClientId: string | null
}
type GrokAuthEntry = {
key?: string
user_id?: string
email?: string
team_id?: string
expires_at?: string
oidc_client_id?: string
}
export type GrokAuthReadResult =
| { status: 'missing' }
| { status: 'error'; error: string }
| { status: 'ok'; session: GrokAuthSession }
function getGrokAuthReadError(err: unknown): string {
if (err instanceof SyntaxError) {
return 'Grok auth file is invalid'
}
// Why: filesystem errors often include the full auth path; renderer/mobile
// account state should not expose local usernames or custom GROK_HOME values.
return 'Unable to read Grok auth file'
}
function parseAuthEntry(value: unknown): GrokAuthEntry | null {
if (typeof value !== 'object' || value === null) {
return null
}
const entry = value as GrokAuthEntry
if (typeof entry.key !== 'string' || entry.key.length === 0) {
return null
}
return entry
}
function parseExpiresAtMs(iso: string | undefined): number | null {
if (!iso) {
return null
}
const ms = Date.parse(iso)
return Number.isFinite(ms) ? ms : null
}
export function readGrokAuthSession(): GrokAuthReadResult {
const path = getGrokAuthPath()
if (!existsSync(path)) {
return { status: 'missing' }
}
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
if (typeof parsed !== 'object' || parsed === null) {
return { status: 'error', error: 'Grok auth file is invalid' }
}
for (const entry of Object.values(parsed)) {
const authEntry = parseAuthEntry(entry)
if (!authEntry?.key) {
continue
}
return {
status: 'ok',
session: {
accessToken: authEntry.key,
userId: typeof authEntry.user_id === 'string' ? authEntry.user_id : null,
email: typeof authEntry.email === 'string' ? authEntry.email : null,
teamId: typeof authEntry.team_id === 'string' ? authEntry.team_id : null,
expiresAtMs: parseExpiresAtMs(authEntry.expires_at),
oidcClientId:
typeof authEntry.oidc_client_id === 'string' ? authEntry.oidc_client_id : null
}
}
}
// Why: a token-less file (e.g. after grok logout) means signed out, not a
// failure — 'error' would keep a status-bar alert visible for that user.
return { status: 'missing' }
} catch (err) {
return {
status: 'error',
error: getGrokAuthReadError(err)
}
}
}
export function hasGrokAuthSession(): boolean {
return readGrokAuthSession().status === 'ok'
}
const TOKEN_SKEW_MS = 5 * 60 * 1000
export function isGrokAccessTokenFresh(session: GrokAuthSession): boolean {
if (session.expiresAtMs === null) {
// Why: auth.json may lack expiry; a bad token still surfaces as billing HTTP 401.
return true
}
return session.expiresAtMs - Date.now() > TOKEN_SKEW_MS
}
+164
View File
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const netFetchMock = vi.hoisted(() => vi.fn())
const authState = vi.hoisted<{
file: string | null
readError: Error | null
}>(() => ({ file: null, readError: null }))
vi.mock('electron', () => ({
net: { fetch: netFetchMock }
}))
vi.mock('node:fs', () => ({
existsSync: () => authState.file !== null,
readFileSync: () => {
if (authState.readError) {
throw authState.readError
}
if (authState.file === null) {
throw new Error('ENOENT')
}
return authState.file
}
}))
vi.mock('node:os', () => ({ homedir: () => '/home/test' }))
import { fetchGrokRateLimits } from './grok-fetcher'
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body
} as Response
}
const BILLING_RESPONSE = {
config: {
creditUsagePercent: 42,
currentPeriod: {
type: 'USAGE_PERIOD_TYPE_WEEKLY',
start: '2026-06-30T18:36:14.268512+00:00',
end: '2026-07-07T18:36:14.268512+00:00'
},
subscriptionTier: 'SuperGrok',
isUnifiedBillingUser: true
}
}
function freshAuthJson(): string {
return JSON.stringify({
'https://auth.x.ai::client': {
key: 'access-token',
user_id: 'user-1',
email: 'dev@example.com',
expires_at: '2099-01-01T00:00:00.000Z'
}
})
}
describe('fetchGrokRateLimits', () => {
beforeEach(() => {
netFetchMock.mockReset()
authState.file = null
authState.readError = null
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns unavailable when not signed in', async () => {
const result = await fetchGrokRateLimits()
expect(result.provider).toBe('grok')
expect(result.status).toBe('unavailable')
expect(netFetchMock).not.toHaveBeenCalled()
})
it('maps weekly credit usage from billing config', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(jsonResponse(BILLING_RESPONSE))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('ok')
expect(result.weekly?.usedPercent).toBe(42)
expect(result.weekly?.windowMinutes).toBe(10_080)
expect(result.usageMetadata?.source).toBe('oauth')
expect(result.usageMetadata?.authProvenance).toContain('SuperGrok')
expect(netFetchMock).toHaveBeenCalledWith(
'https://cli-chat-proxy.grok.com/v1/billing?format=credits',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer access-token',
'X-XAI-Token-Auth': 'xai-grok-cli',
'x-userid': 'user-1'
})
})
)
})
it('returns unavailable when not signed in even if a token-less auth file exists', async () => {
authState.file = JSON.stringify({})
const result = await fetchGrokRateLimits()
expect(result.status).toBe('unavailable')
expect(netFetchMock).not.toHaveBeenCalled()
})
it('returns unavailable when billing has no credit usage', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('unavailable')
expect(result.weekly).toBeNull()
})
it('returns unavailable when billing response has no config', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(jsonResponse({}))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('unavailable')
})
it('aborts the billing request when the caller aborts', async () => {
authState.file = freshAuthJson()
const controller = new AbortController()
let requestSignal: AbortSignal | undefined
netFetchMock.mockImplementationOnce((_url, init: RequestInit) => {
requestSignal = init.signal as AbortSignal
return new Promise((_resolve, reject) => {
requestSignal?.addEventListener('abort', () => reject(new Error('aborted')), {
once: true
})
})
})
const resultPromise = fetchGrokRateLimits({ signal: controller.signal })
await Promise.resolve()
expect(requestSignal?.aborted).toBe(false)
controller.abort()
expect(requestSignal?.aborted).toBe(true)
const result = await resultPromise
expect(result.status).toBe('error')
expect(result.error).toBe('aborted')
})
it('returns error when the session token is expired', async () => {
authState.file = JSON.stringify({
'https://auth.x.ai::client': {
key: 'stale',
expires_at: '2000-01-01T00:00:00.000Z'
}
})
const result = await fetchGrokRateLimits()
expect(result.status).toBe('error')
expect(result.error).toMatch(/expired/i)
expect(netFetchMock).not.toHaveBeenCalled()
})
})
+173
View File
@@ -0,0 +1,173 @@
import { net } from 'electron'
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import {
isGrokAccessTokenFresh,
readGrokAuthSession,
type GrokAuthReadResult,
type GrokAuthSession
} from './grok-auth'
// Why: billing URL and headers must match Grok CLI or xAI rejects the request.
const GROK_CLI_PROXY_BASE =
process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') ||
'https://cli-chat-proxy.grok.com/v1'
const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits`
const API_TIMEOUT_MS = 10_000
const WEEKLY_WINDOW_MINUTES = 10_080
const GROK_CLI_AUTH_HEADER = 'xai-grok-cli'
type GrokMoneyVal = { val?: string | number }
type GrokUsagePeriod = {
type?: string
start?: string
end?: string
}
type GrokBillingConfig = {
creditUsagePercent?: number
currentPeriod?: GrokUsagePeriod
billingPeriodStart?: string
billingPeriodEnd?: string
subscriptionTier?: string
onDemandCap?: GrokMoneyVal
onDemandUsed?: GrokMoneyVal
prepaidBalance?: GrokMoneyVal
isUnifiedBillingUser?: boolean
}
type GrokBillingResponse = GrokBillingConfig & {
config?: GrokBillingConfig
}
function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits {
return {
provider: 'grok',
session: null,
weekly: null,
updatedAt: Date.now(),
error,
status
}
}
function parseResetDescription(isoString: string | undefined): string | null {
if (!isoString) {
return null
}
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) {
return null
}
const isToday = date.toDateString() === new Date().toDateString()
return isToday
? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
: date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })
}
function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null {
const usedPercent = config.creditUsagePercent
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) {
return null
}
const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd
const resetsAt = periodEnd ? Date.parse(periodEnd) : null
return {
usedPercent: Math.min(100, Math.max(0, usedPercent)),
windowMinutes: WEEKLY_WINDOW_MINUTES,
resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null,
resetDescription: parseResetDescription(periodEnd)
}
}
function grokRequestHeaders(session: GrokAuthSession): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${session.accessToken}`,
'X-XAI-Token-Auth': GROK_CLI_AUTH_HEADER,
Accept: 'application/json'
}
if (session.userId) {
headers['x-userid'] = session.userId
}
return headers
}
function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | null {
if (data.config) {
return data.config
}
if (typeof data.creditUsagePercent === 'number') {
return data
}
return null
}
function mapBillingResponse(
data: GrokBillingResponse,
session: GrokAuthSession
): ProviderRateLimits {
const config = resolveBillingConfig(data)
// Why: a 200 without credit usage means the plan has no weekly credits —
// 'unavailable' hides the bar (like Claude on API-key billing); 'error'
// would paint a permanent alert for a signed-in account that has no quota.
if (!config) {
return result('unavailable', 'Grok billing response did not include config')
}
const weekly = mapWeeklyCredits(config)
const tier = config.subscriptionTier?.trim()
const authLabel = session.email?.trim() || session.userId || 'Grok account'
const provenance = tier ? `${authLabel} (${tier})` : authLabel
return {
provider: 'grok',
session: null,
weekly,
updatedAt: Date.now(),
error: weekly ? null : 'Grok billing response did not include credit usage',
status: weekly ? 'ok' : 'unavailable',
usageMetadata: {
source: 'oauth',
authProvenance: provenance
}
}
}
// Why: Orca never runs grok login; it only reads the session file the CLI updates.
export async function fetchGrokRateLimits(
options: { signal?: AbortSignal; authReadResult?: GrokAuthReadResult } = {}
): Promise<ProviderRateLimits> {
const readResult = options.authReadResult ?? readGrokAuthSession()
if (readResult.status === 'missing') {
return result('unavailable', 'Not signed in to Grok — run grok login')
}
if (readResult.status === 'error') {
return result('error', readResult.error)
}
const session = readResult.session
if (!isGrokAccessTokenFresh(session)) {
return result('error', 'Grok session expired — run grok login to refresh')
}
try {
const signal = options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)])
: AbortSignal.timeout(API_TIMEOUT_MS)
const res = await net.fetch(BILLING_CREDITS_URL, {
headers: grokRequestHeaders(session),
signal
})
if (res.status === 401 || res.status === 403) {
return result('error', `Grok usage request unauthorized (HTTP ${res.status})`)
}
if (!res.ok) {
return result('error', `Grok usage request failed (HTTP ${res.status})`)
}
const data: unknown = await res.json()
return mapBillingResponse(
typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {},
session
)
} catch (err) {
return result('error', err instanceof Error ? err.message : 'Grok usage request failed')
}
}
+143 -3
View File
@@ -9,7 +9,10 @@ import { RateLimitService } from './service'
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { readGrokAuthSession } from './grok-auth'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
@@ -30,10 +33,22 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./kimi-fetcher', () => ({
fetchKimiRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
vi.mock('./grok-fetcher', () => ({
fetchGrokRateLimits: vi.fn()
}))
vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -51,8 +66,14 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve }
}
async function flushMicrotasks(times = 4): Promise<void> {
for (let i = 0; i < times; i += 1) {
await Promise.resolve()
}
}
function okProvider(
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok',
usedPercent: number,
updatedAt = Date.now()
): ProviderRateLimits {
@@ -72,7 +93,7 @@ function okProvider(
}
function errorProvider(
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok',
message: string
): ProviderRateLimits {
return {
@@ -126,8 +147,72 @@ describe('RateLimitService', () => {
vi.clearAllMocks()
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now()))
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
vi.mocked(fetchGrokRateLimits).mockResolvedValue({
provider: 'grok',
session: null,
weekly: null,
updatedAt: Date.now(),
error: null,
status: 'unavailable'
})
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
})
it('does not reread Grok auth when callers read state snapshots', () => {
vi.mocked(readGrokAuthSession).mockReturnValue({
status: 'ok',
session: {
accessToken: 'token',
userId: null,
email: null,
teamId: null,
expiresAtMs: null,
oidcClientId: null
}
})
const service = new RateLimitService()
vi.mocked(readGrokAuthSession).mockClear()
expect(service.getState().grokAuthConfigured).toBe(true)
service.getState()
expect(readGrokAuthSession).not.toHaveBeenCalled()
})
it('refreshes Grok without refreshing other providers', async () => {
const authReadResult = {
status: 'ok' as const,
session: {
accessToken: 'token',
userId: null,
email: 'dev@example.com',
teamId: null,
expiresAtMs: null,
oidcClientId: null
}
}
vi.mocked(readGrokAuthSession).mockReturnValue(authReadResult)
vi.mocked(fetchGrokRateLimits).mockResolvedValueOnce(okProvider('grok', 42))
const service = new RateLimitService()
await service.refreshGrok()
expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGrokRateLimits).toHaveBeenCalledWith({
authReadResult,
signal: expect.any(AbortSignal)
})
expect(fetchClaudeRateLimits).not.toHaveBeenCalled()
expect(fetchCodexRateLimits).not.toHaveBeenCalled()
expect(fetchGeminiRateLimits).not.toHaveBeenCalled()
expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled()
expect(fetchKimiRateLimits).not.toHaveBeenCalled()
expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled()
expect(service.getState().grokAuthConfigured).toBe(true)
expect(service.getState().grok?.status).toBe('ok')
})
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
@@ -345,9 +430,47 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
})
it('publishes non-Grok provider results before a slow Grok fetch completes', async () => {
const service = new RateLimitService()
const grok = deferred<ProviderRateLimits>()
let refreshResolved = false
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now()))
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
okProvider('opencode-go', 40, Date.now())
)
vi.mocked(fetchKimiRateLimits).mockResolvedValueOnce(okProvider('kimi', 50, Date.now()))
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 60, Date.now()))
vi.mocked(fetchGrokRateLimits).mockReturnValueOnce(grok.promise)
const refresh = service.refresh().then(() => {
refreshResolved = true
})
await flushMicrotasks()
const pendingGrokState = service.getState()
expect(pendingGrokState.claude?.status).toBe('ok')
expect(pendingGrokState.codex?.status).toBe('ok')
expect(pendingGrokState.gemini?.status).toBe('ok')
expect(pendingGrokState.opencodeGo?.status).toBe('ok')
expect(pendingGrokState.kimi?.status).toBe('ok')
expect(pendingGrokState.minimax?.status).toBe('ok')
expect(pendingGrokState.grok?.status).toBe('fetching')
expect(refreshResolved).toBe(false)
grok.resolve(okProvider('grok', 70, Date.now()))
await refresh
const completedState = service.getState()
expect(completedState.grok?.status).toBe('ok')
expect(refreshResolved).toBe(true)
})
it('aborts the active fetch cycle and clears queued refreshes on stop', async () => {
const service = new RateLimitService()
const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {}
const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal; grok?: AbortSignal } = {}
vi.mocked(fetchClaudeRateLimits).mockImplementation(
(options) =>
@@ -371,6 +494,17 @@ describe('RateLimitService', () => {
)
})
)
vi.mocked(fetchGrokRateLimits).mockImplementation(
(options) =>
new Promise((resolve) => {
capturedSignals.grok = options?.signal
options?.signal?.addEventListener(
'abort',
() => resolve(errorProvider('grok', 'aborted')),
{ once: true }
)
})
)
const activeFetch = serviceInternals(service).fetchAll()
await Promise.resolve()
@@ -383,12 +517,14 @@ describe('RateLimitService', () => {
expect(capturedSignals.claude?.aborted).toBe(true)
expect(capturedSignals.codex?.aborted).toBe(true)
expect(capturedSignals.grok?.aborted).toBe(true)
await queuedRefresh
await activeFetch
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1)
})
it('aborts inactive Claude preview fetches on stop', async () => {
@@ -480,6 +616,10 @@ describe('RateLimitService', () => {
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith('session=abc123', undefined)
expect(fetchGrokRateLimits).toHaveBeenCalledWith({
signal: expect.any(AbortSignal),
authReadResult: { status: 'missing' }
})
const state = service.getState()
expect(state.claude?.status).toBe('ok')
+179 -6
View File
@@ -21,6 +21,8 @@ import {
} from '../claude-accounts/runtime-selection'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { readGrokAuthSession } from './grok-auth'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
@@ -78,6 +80,7 @@ type InternalRateLimitState = {
opencodeGo: ProviderRateLimits | null
kimi: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
}
function normalizePollingInterval(ms: number): number {
@@ -110,8 +113,10 @@ export class RateLimitService {
gemini: null,
opencodeGo: null,
kimi: null,
minimax: null
minimax: null,
grok: null
}
private grokAuthConfigured = readGrokAuthSession().status === 'ok'
private pollInterval: number = DEFAULT_POLL_MS
private timer: ReturnType<typeof setInterval> | null = null
private deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | null = null
@@ -122,6 +127,7 @@ export class RateLimitService {
private fullFetchQueued = false
private codexOnlyFetchQueued = false
private claudeOnlyFetchQueued = false
private grokOnlyFetchQueued = false
private activeFetchAbortControllers = new Set<AbortController>()
private fetchIdleResolvers: (() => void)[] = []
private codexFetchGeneration = 0
@@ -269,6 +275,7 @@ export class RateLimitService {
// its presence on the pushed state so the renderer keeps the MiniMax
// bar visible across reloads and between snapshot refreshes.
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
grokAuthConfigured: this.grokAuthConfigured,
claudeTarget: this.claudeFetchTarget,
codexTarget: this.codexFetchTarget,
inactiveClaudeAccounts: this.buildInactiveArray(
@@ -291,6 +298,11 @@ export class RateLimitService {
return this.getState()
}
async refreshGrok(): Promise<RateLimitState> {
await this.fetchGrokOnly({ force: true })
return this.getState()
}
invalidateMiniMaxCredentialState(): void {
this.minimaxFetchGeneration += 1
// Why: saving or forgetting the browser cookie can race an in-flight usage
@@ -740,6 +752,15 @@ export class RateLimitService {
break
}
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
if (grokSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
@@ -790,6 +811,15 @@ export class RateLimitService {
break
}
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
if (grokSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
@@ -840,6 +870,74 @@ export class RateLimitService {
break
}
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
if (grokSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
this.resolveFetchIdleWaiters()
}
}
private async fetchGrokOnly(options?: { force?: boolean }): Promise<void> {
if (this.isFetching) {
if (options?.force) {
this.grokOnlyFetchQueued = true
return this.waitForFetchIdle()
}
return
}
this.isFetching = true
try {
let shouldContinue = true
while (shouldContinue) {
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
shouldContinue = false
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal)
)
if (fullSignal.aborted) {
break
}
continue
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
shouldContinue = true
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
if (codexSignal.aborted) {
break
}
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal)
)
if (claudeSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
@@ -852,7 +950,8 @@ export class RateLimitService {
!this.isFetching &&
!this.fullFetchQueued &&
!this.codexOnlyFetchQueued &&
!this.claudeOnlyFetchQueued
!this.claudeOnlyFetchQueued &&
!this.grokOnlyFetchQueued
) {
return Promise.resolve()
}
@@ -869,7 +968,8 @@ export class RateLimitService {
this.isFetching ||
this.fullFetchQueued ||
this.codexOnlyFetchQueued ||
this.claudeOnlyFetchQueued
this.claudeOnlyFetchQueued ||
this.grokOnlyFetchQueued
) {
return
}
@@ -913,6 +1013,7 @@ export class RateLimitService {
this.fullFetchQueued = false
this.codexOnlyFetchQueued = false
this.claudeOnlyFetchQueued = false
this.grokOnlyFetchQueued = false
}
private resolveAndClearFetchIdleWaiters(): void {
@@ -1025,7 +1126,7 @@ export class RateLimitService {
private withFetchingStatus(
current: ProviderRateLimits | null,
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok'
): ProviderRateLimits {
if (!current) {
return {
@@ -1064,6 +1165,10 @@ export class RateLimitService {
const miniMaxGroupId = miniMaxConfigResult.config.groupId
const miniMaxModels = miniMaxConfigResult.config.models
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
// Why: getState() is used by renderer pushes and mobile snapshots; keep
// Grok's sync auth-file probe on fetch cycles instead of every state read.
const grokAuthReadResult = readGrokAuthSession()
this.grokAuthConfigured = grokAuthReadResult.status === 'ok'
// Detect if configuration changed — if it did, we must discard any stale
// data because it belongs to a different session/workspace.
@@ -1097,12 +1202,21 @@ export class RateLimitService {
kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
minimax: miniMaxConfigChanged
? this.withFetchingStatus(null, 'minimax')
: this.withFetchingStatus(previousState.minimax, 'minimax')
: this.withFetchingStatus(previousState.minimax, 'minimax'),
grok: this.withFetchingStatus(previousState.grok, 'grok')
})
const missingWslCodexHome = codexHomePath
? null
: this.getMissingWslCodexHomeResult(codexTarget)
const grokResultPromise = fetchGrokRateLimits({
signal,
authReadResult: grokAuthReadResult
}).then(
(value) => ({ status: 'fulfilled', value }) as const,
(reason) => ({ status: 'rejected', reason }) as const
)
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
await Promise.allSettled([
fetchClaudeRateLimits({
@@ -1237,7 +1351,7 @@ export class RateLimitService {
// generation still match, otherwise an old account could overwrite the
// newly selected account's quota state.
this.updateState({
...previousState,
...this.state,
claude: shouldApplyClaude
? this.applyStalePolicy(claude, previousState.claude)
: this.state.claude,
@@ -1258,6 +1372,26 @@ export class RateLimitService {
: this.state.minimax
})
const grokResult = await grokResultPromise
if (signal.aborted) {
return
}
const grok =
grokResult.status === 'fulfilled'
? grokResult.value
: ({
provider: 'grok',
session: null,
weekly: null,
updatedAt: Date.now(),
error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error',
status: 'error'
} satisfies ProviderRateLimits)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
})
this.lastFetchAt = Date.now()
}
@@ -1374,6 +1508,45 @@ export class RateLimitService {
this.lastFetchAt = Date.now()
}
private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return
}
const previousState = this.state
const grokAuthReadResult = readGrokAuthSession()
this.grokAuthConfigured = grokAuthReadResult.status === 'ok'
this.updateState({
...previousState,
grok: this.withFetchingStatus(previousState.grok, 'grok')
})
const grok = await fetchGrokRateLimits({
signal,
authReadResult: grokAuthReadResult
}).catch(
(err): ProviderRateLimits => ({
provider: 'grok',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
})
)
if (signal.aborted) {
return
}
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
})
this.lastFetchAt = Date.now()
}
private applyStalePolicy(
fresh: ProviderRateLimits,
previous: ProviderRateLimits | null
+11 -11
View File
@@ -5459,7 +5459,7 @@ describe('OrcaRuntimeService', () => {
it('keeps project clone setup on the cloned host-qualified repo', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
@@ -5521,7 +5521,7 @@ describe('OrcaRuntimeService', () => {
it('adopts public clone repos into host-qualified project setup', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
@@ -5591,7 +5591,7 @@ describe('OrcaRuntimeService', () => {
it('keeps project clone repos split by runtime host on the same clone path', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = [
{
id: 'repo-host-a',
@@ -5762,7 +5762,7 @@ describe('OrcaRuntimeService', () => {
})
it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const added: Record<string, unknown>[] = []
const colorStore = {
...store,
@@ -5798,7 +5798,7 @@ describe('OrcaRuntimeService', () => {
})
it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
@@ -5896,7 +5896,7 @@ describe('OrcaRuntimeService', () => {
})
it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const runtime = createRuntime()
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
const destination = join(tempRoot, 'destination')
@@ -5913,7 +5913,7 @@ describe('OrcaRuntimeService', () => {
})
it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const runtime = createRuntime()
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
const destination = join(tempRoot, 'destination')
@@ -5930,7 +5930,7 @@ describe('OrcaRuntimeService', () => {
})
it('removes an owned runtime clone target when git exits unsuccessfully', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
spawnSpy.mockReturnValue(proc as never)
@@ -5957,7 +5957,7 @@ describe('OrcaRuntimeService', () => {
})
it('preserves an existing runtime clone target when git exits unsuccessfully', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
spawnSpy.mockReturnValue(proc as never)
@@ -5984,7 +5984,7 @@ describe('OrcaRuntimeService', () => {
})
it('skips runtime clone failure cleanup when the owned target is replaced', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
spawnSpy.mockReturnValue(proc as never)
@@ -6013,7 +6013,7 @@ describe('OrcaRuntimeService', () => {
})
it('serializes concurrent runtime clones for the same target', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
firstProc.stderr = new EventEmitter()
spawnSpy.mockReturnValueOnce(firstProc as never)
+5 -5
View File
@@ -41,7 +41,7 @@ import {
AGENT_PROMPT_SUBMIT_DELAY_MS,
buildAgentPromptPasteBytes
} from '../../shared/agent-prompt-injection'
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
import {
cleanupClaimedCloneTarget,
claimCloneTarget,
@@ -6860,11 +6860,11 @@ export class OrcaRuntimeService {
// `rateLimits:update` IPC channel desktop already uses.
onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void {
const services = this.requireAccountServices()
return services.rateLimits.onStateChange(() => {
return services.rateLimits.onStateChange((rateLimits) => {
listener({
claude: services.claudeAccounts.listAccounts(),
codex: services.codexAccounts.listAccounts(),
rateLimits: services.rateLimits.getState()
rateLimits
})
})
}
@@ -10693,9 +10693,9 @@ export class OrcaRuntimeService {
await mkdir(trimmedDestination, { recursive: true })
const claimedTarget = await claimCloneTarget(clonePath)
await new Promise<void>((resolve, reject) => {
let proc: ReturnType<typeof wslAwareSpawn>
let proc: ReturnType<typeof gitSpawn>
try {
proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], {
proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], {
cwd: trimmedDestination,
stdio: ['ignore', 'ignore', 'pipe']
})
@@ -182,10 +182,11 @@ describe('client UI RPC methods', () => {
...getDefaultUIState(),
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_grokStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
@@ -221,10 +222,11 @@ describe('client UI RPC methods', () => {
const payload = {
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_grokStatusBarDefaultAdded: true,
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
@@ -47,6 +47,7 @@ const StatusBarItem = z.enum([
'opencode-go',
'kimi',
'minimax',
'grok',
'ssh',
'resource-usage',
'ports'
@@ -203,6 +204,7 @@ const UiUpdate = z
_portsStatusBarDefaultAdded: z.boolean().optional(),
_kimiStatusBarDefaultAdded: z.boolean().optional(),
_minimaxStatusBarDefaultAdded: z.boolean().optional(),
_grokStatusBarDefaultAdded: z.boolean().optional(),
statusBarVisible: z.boolean().optional(),
dismissedUpdateVersion: NullableString.optional(),
lastUpdateCheckAt: z.number().finite().nullable().optional(),
+150 -2
View File
@@ -17,6 +17,8 @@ const {
terminated = false
emitStoppedOnStop = true
emitReadyOnInit = HoistedMockWorker.emitReadyOnInit
emitExitOnTerminate = true
terminatePromise: Promise<void> | null = null
messages: WorkerMessage[] = []
private listeners = new Map<string, Set<(...args: unknown[]) => void>>()
@@ -68,8 +70,10 @@ const {
terminate(): Promise<void> {
this.terminated = true
this.emit('exit', 0)
return Promise.resolve()
if (this.emitExitOnTerminate) {
this.emit('exit', 0)
}
return this.terminatePromise ?? Promise.resolve()
}
}
@@ -477,6 +481,95 @@ describe('SttService', () => {
}
})
it('settles stop when the worker exits during stop and does not reuse it', async () => {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
const events: unknown[] = []
await service.startDictation('model-a', (event) => events.push(event), undefined, 'desktop')
const firstWorker = getLastWorker()
expect(firstWorker).toBeDefined()
firstWorker!.emitStoppedOnStop = false
const stopPromise = service.stopDictation('desktop')
firstWorker!.emit('exit', 1)
await stopPromise
expect(events).toContainEqual({ type: 'stopped' })
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
expect(getCreatedWorkerCount()).toBe(2)
expect(getLastWorker()).not.toBe(firstWorker)
})
it('settles stop when the worker errors during stop and shares the outcome with follow-up stop', async () => {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
const events: unknown[] = []
await service.startDictation(
'model-a',
(event) => {
events.push(event)
if (event.type === 'error') {
void service.stopDictation('desktop')
}
},
undefined,
'desktop'
)
const worker = getLastWorker()
expect(worker).toBeDefined()
worker!.emitStoppedOnStop = false
const stopPromise = service.stopDictation('desktop')
worker!.emit('error', new Error('native failure'))
await stopPromise
expect(worker!.messages.filter((message) => message.type === 'stop')).toHaveLength(1)
expect(events).toContainEqual({ type: 'error', error: 'Error: native failure' })
expect(events).toContainEqual({ type: 'stopped' })
})
it('shares one stop message across concurrent stop callers', async () => {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
const worker = getLastWorker()
expect(worker).toBeDefined()
worker!.emitStoppedOnStop = false
const firstStop = service.stopDictation('desktop')
const secondStop = service.stopDictation('desktop')
expect(worker!.messages.filter((message) => message.type === 'stop')).toHaveLength(1)
worker!.emit('message', { type: 'stopped' })
await Promise.all([firstStop, secondStop])
})
it('does not emit synthetic stopped when the worker reports stopped', async () => {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
const events: unknown[] = []
await service.startDictation('model-a', (event) => events.push(event), undefined, 'desktop')
await service.stopDictation('desktop')
expect(events.filter((event) => (event as { type?: string }).type === 'stopped')).toHaveLength(
1
)
})
it('does not retain or reuse a worker that timed out while stopping', async () => {
vi.useFakeTimers()
try {
@@ -505,4 +598,59 @@ describe('SttService', () => {
vi.useRealTimers()
}
})
it('does not wait for worker termination to resolve a timed-out stop', async () => {
vi.useFakeTimers()
try {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
const worker = getLastWorker()
expect(worker).toBeDefined()
worker!.emitStoppedOnStop = false
worker!.emitExitOnTerminate = false
worker!.terminatePromise = new Promise(() => {})
const stopPromise = service.stopDictation('desktop').then(() => 'resolved')
await vi.advanceTimersByTimeAsync(60_000)
const outcome = await Promise.race([stopPromise, Promise.resolve('pending')])
expect(outcome).toBe('resolved')
expect(worker!.terminated).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('rejects idle warm reuse when the model is no longer ready', async () => {
let modelStatus = 'ready'
const getModelState = vi.fn().mockImplementation(async () => ({
id: 'model-a',
status: modelStatus
}))
const service = new SttService({
getModelState,
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
const firstWorker = getLastWorker()
expect(firstWorker).toBeDefined()
await service.stopDictation('desktop')
modelStatus = 'missing'
await expect(service.startDictation('model-a', vi.fn(), undefined, 'desktop')).rejects.toThrow(
'Model not ready: missing'
)
expect(firstWorker!.terminated).toBe(true)
modelStatus = 'ready'
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
expect(getCreatedWorkerCount()).toBe(2)
expect(getLastWorker()).not.toBe(firstWorker)
})
})
+153 -66
View File
@@ -22,6 +22,14 @@ export type SttEvent =
export type SttEventSink = (event: SttEvent) => void
type StopInFlight = {
worker: Worker
owner: string
promise: Promise<void>
}
type StopOutcome = 'stopped' | 'error' | 'exit' | 'timeout'
export class SttService {
private worker: Worker | null = null
private cloudSession: OpenAiTranscriptionSession | null = null
@@ -35,6 +43,7 @@ export class SttService {
private canceledOwners = new Set<string>()
private eventSink: SttEventSink | null = null
private idleTeardownTimer: NodeJS.Timeout | null = null
private stopInFlight: StopInFlight | null = null
// Why: stop resolves only after the worker flushes; in-flight feedAudio IPC
// must not enqueue samples after that flush or they stick on the warm worker
// and contaminate the next dictation session.
@@ -95,8 +104,9 @@ export class SttService {
if (manifest.provider === 'openai') {
if (this.worker) {
const existingWorker = this.worker
await this.stopDictation(owner, { cancelStarting: false })
await this.teardownIdleWorker()
await this.teardownWorker(existingWorker)
}
const modelState = await this.modelManager.getModelState(modelId)
@@ -116,19 +126,37 @@ export class SttService {
await this.stopDictation(owner, { cancelStarting: false })
}
const reusableWorker = this.worker
if (
this.worker &&
reusableWorker &&
this.activeModelId === modelId &&
this.activeHotwordsFilePath === hotwordsFilePath
this.activeHotwordsFilePath === hotwordsFilePath &&
this.stopInFlight?.worker !== reusableWorker
) {
this.eventSink = sink
sink({ type: 'ready' })
return
const worker = reusableWorker
if (!this.activeOwner) {
const modelState = await this.modelManager.getModelState(modelId)
if (modelState.status !== 'ready') {
await this.teardownWorker(worker)
throw new Error(`Model not ready: ${modelState.status}`)
}
}
if (
this.worker === worker &&
this.activeModelId === modelId &&
this.activeHotwordsFilePath === hotwordsFilePath &&
this.stopInFlight?.worker !== worker
) {
this.eventSink = sink
sink({ type: 'ready' })
return
}
}
if (this.worker) {
const existingWorker = this.worker
await this.stopDictation(owner, { cancelStarting: false })
await this.teardownIdleWorker()
await this.teardownWorker(existingWorker)
}
const modelState = await this.modelManager.getModelState(modelId)
@@ -198,12 +226,14 @@ export class SttService {
})
const onWorkerMessage = (msg: SttEvent) => {
this.eventSink?.(msg)
if (this.worker === worker) {
this.eventSink?.(msg)
}
}
const onWorkerError = (err: Error) => {
this.eventSink?.({ type: 'error', error: String(err) })
if (this.worker === worker) {
this.eventSink?.({ type: 'error', error: String(err) })
this.cleanupActiveWorkerLifecycleListeners()
this.worker = null
this.activeModelId = null
@@ -295,9 +325,9 @@ export class SttService {
throw new Error('dictation_owner_mismatch')
}
this.stopping = true
try {
if (this.cloudSession) {
if (this.cloudSession) {
this.stopping = true
try {
const session = this.cloudSession
this.cloudSession = null
try {
@@ -317,76 +347,118 @@ export class SttService {
this.activeOwner = null
this.eventSink = null
}
return
} finally {
this.stopping = false
}
return
}
const worker = this.worker
if (!worker) {
return
const worker = this.worker
if (!worker) {
return
}
if (this.stopInFlight?.worker === worker) {
if (this.stopInFlight.owner !== owner) {
throw new Error('dictation_owner_mismatch')
}
return this.stopInFlight.promise
}
const capturedSink = this.eventSink
let stopPromise!: Promise<void>
stopPromise = this.createStopPromise(worker, capturedSink).finally(() => {
if (this.stopInFlight?.worker === worker && this.stopInFlight.promise === stopPromise) {
this.stopInFlight = null
}
})
this.stopInFlight = { worker, owner, promise: stopPromise }
this.stopping = true
try {
// Why: keep the stop message inside the try so a postMessage throw still
// clears `stopping` — otherwise feedAudio silently drops all future audio.
worker.postMessage({ type: 'stop' })
await stopPromise
} finally {
this.stopping = false
}
}
let forcedTeardown = false
await new Promise<void>((resolve) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | null = null
private createStopPromise(worker: Worker, capturedSink: SttEventSink | null): Promise<void> {
return new Promise<void>((resolve) => {
let settled = false
let receivedStopped = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
worker.off('message', onStopped)
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
worker.off('message', onStopped)
worker.off('error', onError)
worker.off('exit', onExit)
}
const finish = (): void => {
if (settled) {
return
const finish = (outcome: StopOutcome): void => {
if (settled) {
return
}
settled = true
cleanup()
if (outcome === 'stopped') {
if (this.worker === worker) {
this.activeOwner = null
this.eventSink = null
this.scheduleIdleTeardown()
}
settled = true
cleanup()
resolve()
return
}
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
if (msg.type === 'stopped') {
finish()
}
if (!receivedStopped) {
capturedSink?.({ type: 'stopped' })
}
timeout = setTimeout(() => {
if (settled) {
return
}
settled = true
forcedTeardown = true
cleanup()
// Why: a worker that cannot finish dictation is no longer reusable; do
// not keep it in the warm-worker slot or retain its message listeners.
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
void worker.terminate().finally(resolve)
}, STOP_DICTATION_TIMEOUT_MS)
worker.on('message', onStopped)
})
if (this.worker === worker) {
if (forcedTeardown) {
// Why: a worker that cannot finish dictation is no longer reusable; drop
// its lifecycle listeners so a stale worker can't retain this service.
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
if (outcome !== 'exit') {
void worker.terminate().catch(() => undefined)
}
if (this.worker === worker) {
this.worker = null
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
} else {
this.activeOwner = null
this.eventSink = null
this.scheduleIdleTeardown()
}
resolve()
}
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
if (msg.type === 'stopped') {
receivedStopped = true
finish('stopped')
}
}
} finally {
this.stopping = false
}
const onError = (): void => {
finish('error')
}
const onExit = (): void => {
finish('exit')
}
timeout = setTimeout(() => {
finish('timeout')
}, STOP_DICTATION_TIMEOUT_MS)
timeout.unref?.()
worker.on('message', onStopped)
worker.on('error', onError)
worker.on('exit', onExit)
})
}
isActive(): boolean {
@@ -441,8 +513,24 @@ export class SttService {
if (!this.worker || this.activeOwner || this.startingOwner) {
return
}
const worker = this.worker
worker.postMessage({ type: 'teardown' })
await this.teardownWorker(this.worker, options)
}
private async teardownWorker(
worker: Worker,
options: { ignoreTerminateErrors?: boolean } = { ignoreTerminateErrors: true }
): Promise<void> {
this.clearIdleTeardownTimer()
if (this.stopInFlight?.worker === worker) {
await this.stopInFlight.promise
}
try {
worker.postMessage({ type: 'teardown' })
} catch {
// The worker may already have exited on a forced stop path.
}
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
try {
await worker.terminate()
} catch (error) {
@@ -450,12 +538,11 @@ export class SttService {
throw error
}
}
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
if (this.worker === worker) {
this.worker = null
this.activeModelId = null
this.activeHotwordsFilePath = undefined
this.activeOwner = null
this.eventSink = null
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
// Why: different models name their ONNX files differently (e.g.
// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx).
@@ -14,7 +15,7 @@ export function resolveFile(
if (!match) {
throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`)
}
return `${modelDir}/${match}`
return join(modelDir, match)
}
export function resolveTokens(files: string[], modelDir: string): string {
@@ -22,7 +23,7 @@ export function resolveTokens(files: string[], modelDir: string): string {
if (!match) {
throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`)
}
return `${modelDir}/${match}`
return join(modelDir, match)
}
// Why: BPE models need a vocab file for hotwords token matching. The file
@@ -32,7 +33,7 @@ function discoverBpeVocab(modelDir: string): string | undefined {
try {
const entries = readdirSync(modelDir)
const vocabFile = entries.find((f) => f.endsWith('.vocab'))
return vocabFile ? `${modelDir}/${vocabFile}` : undefined
return vocabFile ? join(modelDir, vocabFile) : undefined
} catch {
return undefined
}
+13 -1
View File
@@ -352,6 +352,7 @@ import type {
} from '../shared/claude-usage-types'
import type {
CodexRateLimitResetResult,
GrokAccountStatus,
RateLimitRuntimeTarget,
RateLimitState
} from '../shared/rate-limit-types'
@@ -402,7 +403,12 @@ import type {
OpenCodeUsageSnapshot,
OpenCodeUsageSummary
} from '../shared/opencode-usage-types'
import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types'
import type {
AiVaultListArgs,
AiVaultListResult,
AiVaultSubagentListArgs,
AiVaultSubagentListResult
} from '../shared/ai-vault-types'
import type { AgentType, NativeChatMessage } from '../shared/native-chat-types'
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
@@ -780,6 +786,8 @@ export type OpenCodeUsageApi = {
export type AiVaultApi = {
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
/** Lists the Task subagent transcripts of one session, on demand. */
listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise<AiVaultSubagentListResult>
/** Fires when any app window regains OS focus; returns an unsubscribe. */
onWindowFocused: (callback: () => void) => () => void
}
@@ -2876,6 +2884,7 @@ export type PreloadApi = {
fetchInactiveClaudeAccounts: () => Promise<void>
fetchInactiveCodexAccounts: () => Promise<void>
refreshMiniMax: () => Promise<RateLimitState>
refreshGrok: () => Promise<RateLimitState>
onUpdate: (callback: (state: RateLimitState) => void) => () => void
}
minimaxCredentials: {
@@ -2883,6 +2892,9 @@ export type PreloadApi = {
saveCookie: (cookie: string) => Promise<{ configured: boolean }>
clearCookie: () => Promise<{ configured: boolean }>
}
grokAccounts: {
getStatus: () => Promise<GrokAccountStatus>
}
ssh: {
listTargets: () => Promise<SshTarget[]>
// Removed-target id → last known label, for showing a friendly host name on
+9 -1
View File
@@ -79,6 +79,7 @@ import type {
} from '../shared/mobile-markdown-document'
import type {
CodexRateLimitResetResult,
GrokAccountStatus,
RateLimitRuntimeTarget,
RateLimitState
} from '../shared/rate-limit-types'
@@ -156,7 +157,7 @@ import type {
AutomationUpdateInput
} from '../shared/automations-types'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
import type { AiVaultListArgs } from '../shared/ai-vault-types'
import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types'
import type { AgentType } from '../shared/native-chat-types'
import type {
NativeChatAppendedMessages,
@@ -3681,6 +3682,8 @@ const api = {
aiVault: {
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:listSessions', args),
listSubagentSessions: (args: AiVaultSubagentListArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:listSubagentSessions', args),
onWindowFocused: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('aiVault:windowFocused', listener)
@@ -3851,6 +3854,7 @@ const api = {
fetchInactiveCodexAccounts: (): Promise<void> =>
ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'),
refreshMiniMax: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshMiniMax'),
refreshGrok: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshGrok'),
onUpdate: (callback: (state: RateLimitState) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state)
ipcRenderer.on('rateLimits:update', listener)
@@ -3867,6 +3871,10 @@ const api = {
ipcRenderer.invoke('minimaxCredentials:clearCookie')
},
grokAccounts: {
getStatus: (): Promise<GrokAccountStatus> => ipcRenderer.invoke('grokAccounts:getStatus')
},
ssh: {
listTargets: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:listTargets'),
+47
View File
@@ -331,4 +331,51 @@ describe('RelayAgentHookServer', () => {
vi.unstubAllEnvs()
}
})
it('caps the replay cache at 256 panes, evicting the least-recently-updated', async () => {
// Mirrors the server's private MAX_CACHED_PANES. The WSL relay never gets a
// per-pane teardown signal, so the cache is recency-capped instead.
const CAP = 256
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
const paneKeyFor = (i: number): string => makePaneKey(`tab-${i}`, LEAF_ID)
const postPane = (paneKey: string): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey,
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'p' }
})
})
// Fill the cache to exactly the cap in insertion order 0..CAP-1. Sequential
// awaits pin Map order = update recency, which the eviction relies on.
for (let i = 0; i < CAP; i++) {
await postPane(paneKeyFor(i))
}
// Refresh the OLDEST pane just before overflow, then push one more pane.
// Recency (not insertion) order must now evict pane 1, sparing pane 0.
await postPane(paneKeyFor(0))
await postPane(paneKeyFor(CAP))
forward.mockClear()
const replayed = server.replayCachedPayloadsForPanes()
expect(replayed).toBe(CAP)
const cachedPaneKeys = new Set(forward.mock.calls.map((call) => call[0].paneKey))
expect(cachedPaneKeys.size).toBe(CAP)
expect(cachedPaneKeys.has(paneKeyFor(0))).toBe(true)
expect(cachedPaneKeys.has(paneKeyFor(CAP))).toBe(true)
expect(cachedPaneKeys.has(paneKeyFor(1))).toBe(false)
} finally {
server.stop()
}
}, 30_000)
})
+63 -9
View File
@@ -54,6 +54,12 @@ const ASSISTANT_MESSAGE_RETRY_MS = 50
// '1'/'999'); anything longer is treated as absent.
const MAX_HOOK_META_LEN = 64
// Why: the WSL relay has no per-pane teardown signal (PTYs live on the
// Windows host, so nothing calls clearPaneState), and the replay cache would
// otherwise grow for the relay's lifetime. Recency-cap it; backstop for the
// SSH relay too.
const MAX_CACHED_PANES = 256
function defaultEndpointDir(): string {
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
}
@@ -84,6 +90,15 @@ export type RelayHookServerOptions = {
/** Env tag forwarded into hook payloads. Defaults to "remote", a relay
* location marker that main excludes from dev-vs-prod mismatch warnings. */
env?: string
/** Fixed auth token. The WSL relay passes the host-issued token that
* already crossed into guest env via WSLENV, so unmodified hook clients
* authenticate without any re-coordination. Defaults to a fresh UUID. */
token?: string
/** Preferred bind port. The WSL relay passes the Windows listener's port —
* free inside the guest under NAT, so env-sourced client coords stay
* truthful. Occupied (e.g. mirrored networking) → fall back to :0 and rely
* on the endpoint file for re-coordination. Defaults to :0. */
preferredPort?: number
/** Called once per parsed payload. The relay wires this to
* `dispatcher.notify('agent.hook', envelope)`. */
forward: RelayHookForward
@@ -115,11 +130,16 @@ export class RelayAgentHookServer {
> = new Map()
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private forward: RelayHookForward
private fixedToken: string | undefined
private preferredPort: number
private portFallbackApplied = false
constructor(options: RelayHookServerOptions) {
this.env = options.env ?? REMOTE_AGENT_HOOK_ENV
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
this.fixedToken = options.token
this.preferredPort = options.preferredPort ?? 0
this.forward = options.forward
}
@@ -127,10 +147,37 @@ export class RelayAgentHookServer {
if (this.server) {
return
}
this.token = randomUUID()
this.token = this.fixedToken ?? randomUUID()
this.endpointFileWritten = false
this.portFallbackApplied = false
try {
await this.listenOn(this.preferredPort)
} catch (err) {
// Why: the preferred port is best-effort (WSL relay: the Windows
// listener's port — occupied under mirrored networking, or by an
// unrelated guest process). Fall back to an ephemeral port; clients
// re-coordinate through the endpoint file.
if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') {
this.portFallbackApplied = true
await this.listenOn(0)
} else {
throw err
}
}
if (options.publishEndpoint !== false) {
this.publishEndpointFile()
}
}
/** True when the preferred port was occupied and the server fell back to
* an ephemeral bind — diagnostics for the host-side relay manager. */
get usedPortFallback(): boolean {
return this.portFallbackApplied
}
private listenOn(port: number): Promise<void> {
this.server = createServer((req, res) => this.handleRequest(req, res))
await new Promise<void>((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
// Why: null the server reference on bind failure so a subsequent
@@ -149,16 +196,12 @@ export class RelayAgentHookServer {
if (address && typeof address === 'object') {
this.port = address.port
}
if (options.publishEndpoint !== false) {
this.publishEndpointFile()
}
resolve()
}
this.server!.once('error', onStartupError)
// Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only —
// the agent CLI inside the same remote box reaches us via curl
// 127.0.0.1:PORT; nobody outside the box can.
this.server!.listen(0, '127.0.0.1', onListening)
// Why: loopback only — the agent CLI inside the same remote box reaches
// us via curl 127.0.0.1:PORT; nobody outside the box can.
this.server!.listen(port, '127.0.0.1', onListening)
})
}
@@ -332,8 +375,19 @@ export class RelayAgentHookServer {
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(event.paneKey)
}
// Why: delete-then-set keeps Map insertion order equal to last-update
// recency, so the cache cap below always evicts the longest-idle pane.
this.state.lastStatusByPaneKey.delete(event.paneKey)
this.state.lastStatusByPaneKey.set(event.paneKey, event)
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
const oldest = this.state.lastStatusByPaneKey.keys().next().value
if (oldest === undefined) {
break
}
this.clearPaneState(oldest)
}
this.forwardEvent(event, source, env, version)
}
+3 -3
View File
@@ -19,7 +19,7 @@ import {
ingestGitGrepLine,
SEARCH_TIMEOUT_MS
} from '../shared/text-search'
import { buildRelayCommandEnv } from './relay-command-env'
import { buildRelayGitEnv } from './relay-command-env'
/**
* List files using `git ls-files`. Fallback when rg is not installed.
@@ -61,7 +61,7 @@ export function listFilesWithGit(
const child = spawn('git', ['ls-files', ...args], {
cwd: rootPath,
env: buildRelayCommandEnv(),
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let timer: ReturnType<typeof setTimeout> | null = null
@@ -209,7 +209,7 @@ export function searchWithGitGrep(
const child = spawn('git', gitArgs, {
cwd: rootPath,
env: buildRelayCommandEnv(),
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let killTimeout: ReturnType<typeof setTimeout>
+4 -4
View File
@@ -52,7 +52,7 @@ import {
resolveEffectiveGitUpstream
} from '../shared/git-effective-upstream'
import { loadGitHistoryFromExecutor } from '../shared/git-history'
import { buildRelayCommandEnv } from './relay-command-env'
import { buildRelayGitEnv } from './relay-command-env'
import {
removeSafeUntrackedDiscardTarget,
removeSafeUntrackedDiscardTargets
@@ -254,7 +254,7 @@ export class GitHandler {
stdin?: string
}
): Promise<{ stdout: string; stderr: string }> {
const env = buildRelayCommandEnv()
const env = buildRelayGitEnv()
if (opts?.disableOptionalLocks) {
env.GIT_OPTIONAL_LOCKS = '0'
}
@@ -281,7 +281,7 @@ export class GitHandler {
private async gitBuffer(args: string[], cwd: string): Promise<Buffer> {
const { stdout } = (await execFileAsync('git', args, {
cwd,
env: buildRelayCommandEnv(),
env: buildRelayGitEnv(),
encoding: 'buffer',
maxBuffer: MAX_GIT_BUFFER
})) as { stdout: Buffer }
@@ -1097,7 +1097,7 @@ export class GitHandler {
return await new Promise((resolve, reject) => {
const child = spawn('git', args, {
cwd: expandTilde(cwd),
env: buildRelayCommandEnv(),
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
+18 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { homedir } from 'node:os'
import { buildRelayCommandEnv } from './relay-command-env'
import { buildRelayCommandEnv, buildRelayGitEnv } from './relay-command-env'
// homedir() is the fallback when the relay env carries no HOME; mock it so the
// fallback path is deterministic and the "no resolvable home" branch is reachable.
@@ -228,3 +228,20 @@ describe('buildRelayCommandEnv', () => {
expect(env.PATH?.split(':')).toContain('/home/fallback/.local/bin')
})
})
describe('buildRelayGitEnv', () => {
it('pins an untranslated locale on top of the command env (issue #7808)', () => {
// Relay git stderr/progress is machine-parsed; a non-English host locale
// with a gettext git would translate it and break the parsers.
const env = buildRelayGitEnv(
{ HOME: '/home/me', PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' },
'linux'
)
expect(env.LC_ALL).toBe('en_US.UTF-8')
expect(env.LANG).toBe('en_US.UTF-8')
expect(env.LANGUAGE).toBe('en')
// PATH fallback behavior is unchanged.
expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/usr/bin', '/usr/local/bin']))
})
})
+14
View File
@@ -1,5 +1,6 @@
import { homedir } from 'node:os'
import { posix, win32 } from 'node:path'
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../shared/git-output-locale'
const POSIX_RELAY_PATH_FALLBACKS = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin']
const WINDOWS_RELAY_PATH_FALLBACKS = [
@@ -154,3 +155,16 @@ export function buildRelayCommandEnv(
[key]: [...segments].join(delimiter)
}
}
/**
* Env for relay-spawned git specifically: the PATH fallbacks above plus a
* pinned English UTF-8 locale, because relay git output is machine-parsed
* (stderr phrase matching, progress regexes) and a gettext-enabled git under
* a non-English host locale translates it (issue #7808).
*/
export function buildRelayGitEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return { ...buildRelayCommandEnv(baseEnv, platform), ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
+195
View File
@@ -0,0 +1,195 @@
// Guest-side WSL hook relay pieces: host-given port/token binding with
// ephemeral fallback, and the shared endpoint-path contract.
import { createServer } from 'node:net'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayAgentHookServer } from './agent-hook-server'
import { makePaneKey } from '../shared/stable-pane-id'
import {
sanitizeWslHookInstanceKey,
wslHookRelayEndpointDir,
wslHookRelayEndpointFilePath
} from '../shared/wsl-hook-relay-contract'
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
const PANE_KEY = makePaneKey('tab-2', LEAF_ID)
// Parse an endpoint file tolerantly across platforms: POSIX writes `KEY=value`,
// Windows writes `set KEY=value`. Strips the optional `set ` prefix.
function parseEndpointFile(contents: string): Record<string, string> {
const env: Record<string, string> = {}
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.replace(/^set /, '')
const eq = line.indexOf('=')
if (eq > 0) {
env[line.slice(0, eq)] = line.slice(eq + 1)
}
}
return env
}
describe('RelayAgentHookServer host-given coordinates (WSL relay)', () => {
let tmpDir: string
let server: RelayAgentHookServer | null
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'wsl-hook-relay-'))
server = null
})
afterEach(() => {
server?.stop()
rmSync(tmpDir, { recursive: true, force: true })
})
it('binds the preferred port with the fixed token when the port is free', async () => {
const probe = createServer()
const freePort = await new Promise<number>((resolve) => {
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
await new Promise<void>((resolve) => probe.close(() => resolve()))
server = new RelayAgentHookServer({
endpointDir: tmpDir,
token: 'host-issued-token',
preferredPort: freePort,
forward: () => {}
})
await server.start()
expect(server.getCoordinates().port).toBe(freePort)
expect(server.getCoordinates().token).toBe('host-issued-token')
expect(server.usedPortFallback).toBe(false)
})
it('falls back to an ephemeral port when the preferred port is occupied', async () => {
const occupant = createServer()
const occupiedPort = await new Promise<number>((resolve) => {
occupant.listen(0, '127.0.0.1', () => {
const address = occupant.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
try {
server = new RelayAgentHookServer({
endpointDir: tmpDir,
token: 'host-issued-token',
preferredPort: occupiedPort,
forward: () => {}
})
await server.start()
expect(server.usedPortFallback).toBe(true)
const bound = server.getCoordinates().port
expect(bound).toBeGreaterThan(0)
expect(bound).not.toBe(occupiedPort)
// Fallback still authenticates with the host-issued token.
expect(server.getCoordinates().token).toBe('host-issued-token')
} finally {
await new Promise<void>((resolve) => occupant.close(() => resolve()))
}
})
it('keeps random-token ephemeral behavior when no coordinates are given (SSH shape)', async () => {
server = new RelayAgentHookServer({ endpointDir: tmpDir, forward: () => {} })
await server.start()
expect(server.getCoordinates().port).toBeGreaterThan(0)
expect(server.getCoordinates().token).toMatch(/^[0-9a-f-]{36}$/)
expect(server.usedPortFallback).toBe(false)
})
it('rejects a wrong token but authenticates the host-issued fixed token', async () => {
const forward = vi.fn()
server = new RelayAgentHookServer({ endpointDir: tmpDir, token: 'host-issued-token', forward })
await server.start()
const { port } = server.getCoordinates()
const rejected = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': 'wrong-token' },
body: '{}'
})
expect(rejected.status).toBe(403)
expect(forward).not.toHaveBeenCalled()
const accepted = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': 'host-issued-token'
},
body: JSON.stringify({
paneKey: PANE_KEY,
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
})
})
expect(accepted.status).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
expect(forward.mock.calls[0][0].paneKey).toBe(PANE_KEY)
})
it('publishes the fallback port + fixed token into the endpoint file after EADDRINUSE', async () => {
const occupant = createServer()
const occupiedPort = await new Promise<number>((resolve) => {
occupant.listen(0, '127.0.0.1', () => {
const address = occupant.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
try {
server = new RelayAgentHookServer({
endpointDir: tmpDir,
token: 'host-issued-token',
preferredPort: occupiedPort,
forward: () => {}
})
await server.start()
expect(server.usedPortFallback).toBe(true)
const { port, token, endpointFilePath } = server.getCoordinates()
expect(port).not.toBe(occupiedPort)
// The endpoint file is the client re-coordination contract: surviving
// agents re-source it and MUST see the actual fallback port, not the
// occupied preferred port.
const published = parseEndpointFile(readFileSync(endpointFilePath, 'utf8'))
expect(published.ORCA_AGENT_HOOK_PORT).toBe(String(port))
expect(published.ORCA_AGENT_HOOK_PORT).not.toBe(String(occupiedPort))
expect(published.ORCA_AGENT_HOOK_TOKEN).toBe(token)
expect(published.ORCA_AGENT_HOOK_TOKEN).toBe('host-issued-token')
} finally {
await new Promise<void>((resolve) => occupant.close(() => resolve()))
}
})
})
describe('wsl hook relay endpoint contract', () => {
it('derives the endpoint dir from guest home and the restart-stable instance key', () => {
expect(wslHookRelayEndpointDir('/home/u', 'abc123')).toBe(
'/home/u/.orca-wsl/agent-hooks/instance-abc123'
)
expect(wslHookRelayEndpointDir('/home/u/', 'abc123')).toBe(
'/home/u/.orca-wsl/agent-hooks/instance-abc123'
)
})
it('names the guest endpoint file endpoint.env regardless of host platform', () => {
expect(wslHookRelayEndpointFilePath('/home/u', 'k1')).toBe(
'/home/u/.orca-wsl/agent-hooks/instance-k1/endpoint.env'
)
})
it('sanitizes instance keys to a shell/path-inert alphabet', () => {
expect(sanitizeWslHookInstanceKey('ABCdef012')).toBe('abcdef012')
expect(sanitizeWslHookInstanceKey(' a-1 ')).toBe('a-1')
expect(sanitizeWslHookInstanceKey('bad key$')).toBeNull()
expect(sanitizeWslHookInstanceKey('')).toBeNull()
expect(sanitizeWslHookInstanceKey(undefined)).toBeNull()
})
})
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
// Guest-resident WSL agent-hook relay (STA-1515). Runs inside a WSL distro,
// binds a loopback hook receiver on the very port the Windows host issued
// (free under NAT — that port only exists Windows-side), and forwards every
// parsed hook envelope to Orca over this process's own stdin/stdout using the
// framed JSON-RPC protocol the SSH relay already speaks. Also hosts the
// home-scoped fs bridge the host uses to install hook configs into the guest.
//
// Lifecycle: dies when stdin closes. A lingering guest listener would let
// WSL's Windows→WSL forwarder grab the freed Windows-side port and blackhole
// stale Windows-side hook posts — so unlike the SSH relay there is no grace
// period and no daemon socket.
import { homedir } from 'node:os'
import { RELAY_SENTINEL } from './protocol'
import { RelayDispatcher } from './dispatcher'
import { RelayAgentHookServer } from './agent-hook-server'
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
import {
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../shared/agent-hook-relay'
import {
sanitizeWslHookInstanceKey,
WSL_HOOK_RELAY_INSTANCE_ENV,
wslHookRelayEndpointDir
} from '../shared/wsl-hook-relay-contract'
async function main(): Promise<void> {
const windowsPort = Number(process.env.ORCA_AGENT_HOOK_PORT ?? '')
const token = process.env.ORCA_AGENT_HOOK_TOKEN ?? ''
if (!Number.isInteger(windowsPort) || windowsPort <= 0 || token.length === 0) {
process.stderr.write('[wsl-hook-relay] missing ORCA_AGENT_HOOK_PORT/TOKEN in env\n')
process.exit(1)
}
let stdoutAlive = true
const dispatcher = new RelayDispatcher((data) => {
if (!stdoutAlive) {
return
}
return process.stdout.write(data)
})
// Why: restart-stable instance key keeps the endpoint file at one path
// across app restarts so surviving agents re-coordinate off its rewrite.
const instanceKey =
sanitizeWslHookInstanceKey(process.env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? `port${windowsPort}`
const hookServer = new RelayAgentHookServer({
endpointDir: wslHookRelayEndpointDir(homedir(), instanceKey),
token,
preferredPort: windowsPort,
forward: (envelope) =>
dispatcher.notify(
AGENT_HOOK_NOTIFICATION_METHOD,
envelope as unknown as Record<string, unknown>
)
})
dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: hookServer.replayCachedPayloadsForPanes()
}))
registerWslHookFsHandlers(dispatcher, homedir(), () => ({
portFallback: hookServer.usedPortFallback,
boundPort: hookServer.getCoordinates().port
}))
try {
await hookServer.start()
} catch (err) {
process.stderr.write(
`[wsl-hook-relay] hook server bind failed: ${err instanceof Error ? err.message : String(err)}\n`
)
process.exit(1)
}
if (hookServer.usedPortFallback) {
// Why: diagnosable breadcrumb — hook clients are fail-open silent, the
// relay must not be. Fallback is expected under mirrored networking.
process.stderr.write(
`[wsl-hook-relay] port ${windowsPort} occupied; bound ${hookServer.getCoordinates().port} (endpoint-file re-coordination)\n`
)
}
const shutdown = (): void => {
stdoutAlive = false
dispatcher.dispose()
hookServer.stop()
process.exit(0)
}
process.stdin.on('data', (chunk: Buffer) => dispatcher.feed(chunk))
process.stdin.on('end', shutdown)
process.stdin.on('error', shutdown)
process.stdout.on('error', shutdown)
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
// Why: same posture as the SSH relay — an uncaught exception may leave
// broken invariants, so exit and let the host manager respawn a clean
// relay; a stray rejection is logged and survived (hook delivery must not
// die for a non-fatal async error).
process.on('uncaughtException', (err) => {
process.stderr.write(`[wsl-hook-relay] uncaught exception: ${err.message}\n`)
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
process.stderr.write(`[wsl-hook-relay] unhandled rejection: ${String(reason)}\n`)
})
// Signal readiness — the host watches for this exact string before
// sending framed data (same contract as the SSH relay).
process.stdout.write(RELAY_SENTINEL)
}
void main()
+143
View File
@@ -0,0 +1,143 @@
// Why: the fs bridge is POSIX-by-design — it resolves every path with
// `posix.resolve` against a POSIX guest home. On win32 `posix.resolve` of a
// Windows tmpdir yields an invalid path, so the whole suite is skipped there
// (the bridge only ever runs inside a Linux WSL guest).
import { mkdtempSync, rmSync, statSync } from 'node:fs'
import { posix } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
import type { MethodHandler, RelayDispatcher, RequestContext } from './dispatcher'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../shared/wsl-hook-relay-contract'
describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs bridge)', () => {
let home: string
let handlers: Map<string, MethodHandler>
const context: RequestContext = { clientId: 1, isStale: () => false }
const call = async <T extends object = Record<string, unknown>>(
method: string,
params: Record<string, unknown> = {}
): Promise<WslFsResult<T>> => {
const handler = handlers.get(method)
if (!handler) {
throw new Error(`no handler registered for ${method}`)
}
return (await handler(params, context)) as WslFsResult<T>
}
beforeEach(() => {
home = mkdtempSync(posix.join(tmpdir(), 'wsl-fs-home-'))
handlers = new Map<string, MethodHandler>()
// Capture handlers from a minimal fake dispatcher — registration only ever
// calls onRequest, so a real RelayDispatcher is unnecessary.
const dispatcher = {
onRequest: (method: string, handler: MethodHandler) => {
handlers.set(method, handler)
}
} as unknown as RelayDispatcher
registerWslHookFsHandlers(dispatcher, home, () => ({ fallbackPort: 4321 }))
})
afterEach(() => {
rmSync(home, { recursive: true, force: true })
})
it('returns the resolved home and merges linkStatus extras', async () => {
const result = await call<{ home: string; fallbackPort: number }>(WSL_HOOK_FS_METHODS.home)
expect(result).toMatchObject({ ok: true, home: posix.resolve(home), fallbackPort: 4321 })
})
it('round-trips writeFile + readFile inside home', async () => {
const path = posix.join(home, 'note.txt')
const write = await call(WSL_HOOK_FS_METHODS.writeFile, { path, content: 'hello guest' })
expect(write.ok).toBe(true)
const read = await call<{ content: string }>(WSL_HOOK_FS_METHODS.readFile, { path })
expect(read).toEqual({ ok: true, content: 'hello guest' })
})
it('refuses writeFile to an absolute path outside home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
path: '/etc/orca-evil.txt',
content: 'x'
})
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses a `..` traversal that escapes home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
path: `${home}/../escape.txt`,
content: 'x'
})
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses a sibling dir that shares home as a string prefix', async () => {
// `${home}-evil/x` starts with homeRoot but not with `${homeRoot}/`.
const result = await call(WSL_HOOK_FS_METHODS.writeFile, {
path: `${home}-evil/x.txt`,
content: 'x'
})
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('reports ENOENT for a missing file inside home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.readFile, {
path: posix.join(home, 'does-not-exist.txt')
})
expect(result).toMatchObject({ ok: false, errno: 'ENOENT' })
})
it('allows readdir existence probes on an ancestor of home and on /', async () => {
const ancestor = await call<{ entries: { filename: string }[] }>(WSL_HOOK_FS_METHODS.readdir, {
path: posix.dirname(home)
})
expect(ancestor.ok).toBe(true)
const root = await call<{ entries: { filename: string }[] }>(WSL_HOOK_FS_METHODS.readdir, {
path: '/'
})
expect(root.ok).toBe(true)
})
it('refuses readdir on a non-ancestor dir outside home', async () => {
const result = await call(WSL_HOOK_FS_METHODS.readdir, { path: '/etc' })
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses rename crossing the home boundary in either direction', async () => {
const inside = posix.join(home, 'src.txt')
await call(WSL_HOOK_FS_METHODS.writeFile, { path: inside, content: 'x' })
const outbound = await call(WSL_HOOK_FS_METHODS.rename, {
src: inside,
dst: '/etc/orca-evil.txt'
})
expect(outbound).toMatchObject({ ok: false, errno: 'EACCES' })
const inbound = await call(WSL_HOOK_FS_METHODS.rename, {
src: '/etc/passwd',
dst: posix.join(home, 'stolen.txt')
})
expect(inbound).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses a relative path (resolved against cwd, which lands outside home)', async () => {
const result = await call(WSL_HOOK_FS_METHODS.readFile, { path: 'foo.txt' })
expect(result).toMatchObject({ ok: false, errno: 'EACCES' })
})
it('refuses mkdir outside home and creates a dir inside home', async () => {
const outside = await call(WSL_HOOK_FS_METHODS.mkdir, { path: '/etc/orca-evil-dir' })
expect(outside).toMatchObject({ ok: false, errno: 'EACCES' })
const dir = posix.join(home, 'newdir')
const inside = await call(WSL_HOOK_FS_METHODS.mkdir, { path: dir })
expect(inside.ok).toBe(true)
expect(statSync(dir).isDirectory()).toBe(true)
})
it('fails without crashing when chmod gets a non-numeric mode', async () => {
const path = posix.join(home, 'perm.txt')
await call(WSL_HOOK_FS_METHODS.writeFile, { path, content: 'x' })
const result = await call(WSL_HOOK_FS_METHODS.chmod, { path, mode: 'not-a-number' })
expect(result.ok).toBe(false)
})
})
+153
View File
@@ -0,0 +1,153 @@
// Guest-side fs bridge for the WSL agent-hook relay. Exposes the handful of
// home-scoped file operations the shared remote hook installers need, as
// JSON-RPC request handlers over the relay's stdio channel — so the host
// installs hook configs/scripts into the distro without per-file wsl.exe
// spawns (load-sensitive, see docs/agent-status-over-wsl.md).
import { promises as fs } from 'node:fs'
import { posix } from 'node:path'
import type { RelayDispatcher } from './dispatcher'
import {
WSL_HOOK_FS_METHODS,
type WslFsFailure,
type WslFsResult
} from '../shared/wsl-hook-relay-contract'
function failure(err: unknown): WslFsFailure {
const e = err as NodeJS.ErrnoException
return { ok: false, errno: e?.code ?? 'EUNKNOWN', message: e?.message ?? String(err) }
}
export function registerWslHookFsHandlers(
dispatcher: RelayDispatcher,
home: string,
// Why: the home request doubles as the connect handshake; link diagnostics
// (port fallback) ride it so the host can breadcrumb them.
linkStatus?: () => Record<string, unknown>
): void {
// Why: the bridge exists solely to write agent hook configs into the guest
// user's home. Refusing paths outside it bounds the blast radius of a
// compromised host-side caller to what hook installation touches anyway.
// The bound is lexical (symlinks inside home are followed) — acceptable
// because the only caller is the host-owned stdio channel, never an
// agent-reachable surface.
const homeRoot = posix.resolve(home)
const resolveRaw = (rawPath: unknown): string => {
if (typeof rawPath !== 'string' || rawPath.length === 0) {
throw Object.assign(new Error('invalid path'), { code: 'EINVAL' })
}
return posix.resolve(rawPath)
}
const scoped = (rawPath: unknown): string => {
const resolved = resolveRaw(rawPath)
if (resolved !== homeRoot && !resolved.startsWith(`${homeRoot}/`)) {
throw Object.assign(new Error(`path outside home: ${resolved}`), { code: 'EACCES' })
}
return resolved
}
// Why: the installers' mkdir-p walks top-down from `/`, probing every
// ancestor of home with readdir before it ever creates a dir. Allow
// read-only existence probes on those ancestors; everything else stays
// home-scoped.
const scopedProbe = (rawPath: unknown): string => {
const resolved = resolveRaw(rawPath)
if (resolved === '/' || homeRoot === resolved || homeRoot.startsWith(`${resolved}/`)) {
return resolved
}
return scoped(rawPath)
}
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.home,
async (): Promise<WslFsResult<{ home: string }>> => {
return { ok: true, home: homeRoot, ...linkStatus?.() }
}
)
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.readFile,
async (params): Promise<WslFsResult<{ content: string }>> => {
try {
const content = await fs.readFile(scoped(params.path), 'utf8')
return { ok: true, content }
} catch (err) {
return failure(err)
}
}
)
dispatcher.onRequest(WSL_HOOK_FS_METHODS.writeFile, async (params): Promise<WslFsResult> => {
try {
const mode = typeof params.mode === 'number' ? params.mode : undefined
await fs.writeFile(scoped(params.path), String(params.content ?? ''), {
encoding: 'utf8',
mode
})
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.stat,
async (params): Promise<WslFsResult<{ mode: number }>> => {
try {
const stats = await fs.stat(scoped(params.path))
return { ok: true, mode: stats.mode }
} catch (err) {
return failure(err)
}
}
)
dispatcher.onRequest(WSL_HOOK_FS_METHODS.rename, async (params): Promise<WslFsResult> => {
try {
// Why: POSIX rename overwrites atomically — exactly the OpenSSH
// overwrite-rename semantics the installers prefer.
await fs.rename(scoped(params.src), scoped(params.dst))
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(WSL_HOOK_FS_METHODS.unlink, async (params): Promise<WslFsResult> => {
try {
await fs.unlink(scoped(params.path))
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(WSL_HOOK_FS_METHODS.chmod, async (params): Promise<WslFsResult> => {
try {
await fs.chmod(scoped(params.path), Number(params.mode))
return { ok: true }
} catch (err) {
return failure(err)
}
})
dispatcher.onRequest(
WSL_HOOK_FS_METHODS.readdir,
async (params): Promise<WslFsResult<{ entries: { filename: string }[] }>> => {
try {
const names = await fs.readdir(scopedProbe(params.path))
return { ok: true, entries: names.map((filename) => ({ filename })) }
} catch (err) {
return failure(err)
}
}
)
dispatcher.onRequest(WSL_HOOK_FS_METHODS.mkdir, async (params): Promise<WslFsResult> => {
try {
await fs.mkdir(scoped(params.path))
return { ok: true }
} catch (err) {
return failure(err)
}
})
}
+10 -2
View File
@@ -18,6 +18,9 @@ export type AgentDotState =
| 'blocked'
| 'waiting'
| 'interrupted'
// Why: AI Vault subagent rows report a transcript-derived failure, which is
// an outcome (like 'done'), not a live attention state like 'blocked'.
| 'failed'
| 'done'
| 'idle'
// Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard)
@@ -37,6 +40,8 @@ export function agentStateLabel(state: AgentDotState): string {
return 'Waiting for input'
case 'interrupted':
return 'Interrupted'
case 'failed':
return 'Failed'
case 'done':
return 'Done'
case 'idle':
@@ -71,8 +76,11 @@ export const AgentStateDot = React.memo(function AgentStateDot({
className={cn(
// Why: match the sidebar worktree spinner's stepped cadence so
// long-running visible agents do not keep a full-frame-rate loop.
// Sized like the check icon (not the filled dots): both are
// outline glyphs, so a smaller ring reads visibly undersized
// next to a 'done' check in the same list.
'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]',
inner
icon
)}
/>
</span>
@@ -105,7 +113,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({
inner,
state === 'permission' || state === 'waiting'
? 'bg-amber-500'
: state === 'blocked' || state === 'interrupted'
: state === 'blocked' || state === 'interrupted' || state === 'failed'
? 'bg-red-500'
: 'bg-neutral-500/40'
)}
@@ -70,7 +70,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const agentCmdOverrides = settings?.agentCmdOverrides
const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } =
const { getOriginalPaneTarget, getSessionLiveState, jumpToOriginalPane, jumpToWorktree } =
useAiVaultOriginalPaneActions()
const [query, setQuery] = useState('')
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
@@ -372,6 +372,7 @@ export default function AiVaultPanel(): React.JSX.Element {
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
getOriginalPaneTarget={getOriginalPaneTarget}
getSessionLiveState={getSessionLiveState}
getWorktreeInfo={(session) => sessionWorktreeById.get(session.id) ?? null}
onToggleGroup={toggleGroup}
onJumpToOriginalPane={jumpToOriginalPane}
@@ -10,6 +10,7 @@ import {
} from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { sessionDetailConversationTurns } from './ai-vault-session-display'
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice'
import {
aiVaultWorktreeCompactPath,
@@ -97,6 +98,8 @@ export function SessionInlineDetails({
<SessionUnsavedConversationNotice session={session} logAvailable={Boolean(onOpenLog)} />
)}
<SessionSubagentsSection session={session} />
{shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, {
vaultScope
}) ? (
@@ -25,9 +25,11 @@ import {
SessionMetadata,
SessionWorktreeLine
} from './ai-vault-session-row-display'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
export function VaultSessionRow({
session,
liveState,
resumeStartup,
worktreeInfo,
vaultScope,
@@ -50,6 +52,7 @@ export function VaultSessionRow({
onOpenCwd
}: {
session: AiVaultSession
liveState: AgentStatusState | null
resumeStartup: AiVaultResumeStartup
worktreeInfo: AiVaultSessionWorktreeInfo | null
vaultScope: AiVaultScope
@@ -178,6 +181,7 @@ export function VaultSessionRow({
</div>
<SessionMetadata
session={session}
liveState={liveState}
updatedAt={updatedAt}
worktreeInfo={worktreeInfo}
vaultScope={vaultScope}
@@ -0,0 +1,99 @@
// @vitest-environment happy-dom
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultSession, AiVaultSubagentListResult } from '../../../../shared/ai-vault-types'
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
const listSubagentSessions = vi.fn<(args: unknown) => Promise<AiVaultSubagentListResult>>()
beforeEach(() => {
listSubagentSessions.mockReset()
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim
;(window as any).api = {
aiVault: { listSubagentSessions },
shell: { openFilePath: vi.fn() }
}
})
afterEach(() => {
document.body.replaceChildren()
})
function makeSession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
id: 'local:claude:parent-session:/tmp/parent-session.jsonl',
executionHostId: 'local',
agent: 'claude',
sessionId: 'parent-session',
title: 'Parent session',
cwd: '/repo',
branch: null,
model: null,
filePath: '/tmp/parent-session.jsonl',
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-07-05T10:00:00.000Z',
messageCount: 3,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 1,
resumeCommand: 'claude --resume parent-session',
subagent: null,
...overrides
}
}
function makeSubagent(title: string): AiVaultSession {
return makeSession({
id: `local:claude:parent-session:/tmp/parent-session/subagents/agent-${title}.jsonl`,
title,
filePath: `/tmp/parent-session/subagents/agent-${title}.jsonl`,
subagent: { parentSessionId: 'parent-session', agentType: null, status: 'running' },
subagentTranscriptCount: 0
})
}
describe('SessionSubagentsSection', () => {
it('keeps the loaded list visible while a rescan-triggered refetch is in flight', async () => {
listSubagentSessions.mockResolvedValueOnce({
sessions: [makeSubagent('First pass')],
issues: []
})
const { rerender, queryByText } = render(<SessionSubagentsSection session={makeSession()} />)
await act(async () => {})
expect(queryByText('First pass')).not.toBeNull()
// Second fetch stays pending: the previous rows must remain visible
// instead of flickering back to the hidden loading state.
let resolveSecond: (result: AiVaultSubagentListResult) => void = () => {}
listSubagentSessions.mockImplementationOnce(
() => new Promise((resolve) => (resolveSecond = resolve))
)
rerender(
<SessionSubagentsSection session={makeSession({ modifiedAt: '2026-07-05T10:05:00.000Z' })} />
)
await act(async () => {})
expect(listSubagentSessions).toHaveBeenCalledTimes(2)
expect(queryByText('First pass')).not.toBeNull()
await act(async () => {
resolveSecond({ sessions: [makeSubagent('Second pass')], issues: [] })
})
expect(queryByText('Second pass')).not.toBeNull()
expect(queryByText('First pass')).toBeNull()
})
it('does not fetch for remote sessions even when the scan counted transcripts', async () => {
const { container } = render(
<SessionSubagentsSection
session={makeSession({ executionHostId: 'ssh:dev-box', subagentTranscriptCount: 2 })}
/>
)
await act(async () => {})
expect(listSubagentSessions).not.toHaveBeenCalled()
expect(container.firstChild).toBeNull()
})
})
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react'
import type React from 'react'
import { Bot, FileJson } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot'
import type { AiVaultSession, AiVaultSubagentRunStatus } from '../../../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
type SubagentListState = { status: 'loading' } | { status: 'loaded'; sessions: AiVaultSession[] }
/**
* Lists the Task subagent transcripts spawned by one session, fetched on
* demand when the parent's details expand. Subagents share the parent's
* sessionId and aren't independently resumable, so rows are view-only.
*/
export function SessionSubagentsSection({
session
}: {
session: AiVaultSession
}): React.JSX.Element | null {
const subagents = useSubagentSessions(session)
if (subagents.status !== 'loaded' || subagents.sessions.length === 0) {
return null
}
return (
<section className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span className="text-muted-foreground/80">
<Bot className="size-3" />
</span>
<span>
{translate(
'auto.components.right.sidebar.AiVaultSessionSubagents.subagentsCount',
'Subagents ({{value0}})',
{ value0: subagents.sessions.length }
)}
</span>
</div>
<div className="space-y-1.5">
{subagents.sessions.map((subagentSession) => (
<SubagentSessionLine key={subagentSession.id} session={subagentSession} />
))}
</div>
</section>
)
}
function useSubagentSessions(session: AiVaultSession): SubagentListState {
const [state, setState] = useState<SubagentListState>({ status: 'loading' })
useEffect(() => {
// The scan already counted the transcripts; skip the IPC round-trip when
// there is nothing to list. Remote sessions can carry a count (from the
// remote walk listing), but their transcripts aren't local files to list.
if (
session.subagentTranscriptCount === 0 ||
session.executionHostId !== LOCAL_EXECUTION_HOST_ID
) {
setState({ status: 'loaded', sessions: [] })
return
}
let cancelled = false
// Why: rescans re-run this effect (modifiedAt changes); resetting to
// loading would unmount the section until IPC returns and flicker on
// every active-session rescan. Keep prior rows visible while refetching.
setState((prev) => (prev.status === 'loaded' ? prev : { status: 'loading' }))
window.api.aiVault
.listSubagentSessions({
agent: session.agent,
parentFilePath: session.filePath,
executionHostId: session.executionHostId
})
.then((result) => {
if (!cancelled) {
setState({ status: 'loaded', sessions: result.sessions })
}
})
.catch(() => {
// A failed listing degrades to "no subagents" — the section stays hidden.
if (!cancelled) {
setState({ status: 'loaded', sessions: [] })
}
})
return () => {
cancelled = true
}
// Why: modifiedAt changes exactly when the parent transcript is rewritten,
// so re-listing on it refreshes a subagent's status (e.g. running -> done).
}, [
session.agent,
session.filePath,
session.executionHostId,
session.subagentTranscriptCount,
session.modifiedAt
])
return state
}
// AI Vault run statuses map onto the shared dot vocabulary: a completed Task
// is an outcome ('done'), stopped/killed reads as an interruption.
const SUBAGENT_DOT_STATES: Record<AiVaultSubagentRunStatus, AgentDotState> = {
running: 'working',
completed: 'done',
failed: 'failed',
stopped: 'interrupted'
}
function SubagentSessionLine({ session }: { session: AiVaultSession }): React.JSX.Element {
const dotState = session.subagent?.status ? SUBAGENT_DOT_STATES[session.subagent.status] : null
return (
<div className="flex min-w-0 items-center gap-1.5 rounded-md border border-sidebar-border/70 bg-sidebar-accent/25 px-2.5 py-1.5">
{dotState ? (
// Why: a plain inline span would baseline-align the dot; flex keeps it
// vertically centered with the row text.
<span className="flex shrink-0 items-center" title={agentStateLabel(dotState)}>
<AgentStateDot state={dotState} />
</span>
) : null}
<span
className="min-w-0 flex-1 truncate text-[12px] leading-[1.35] text-foreground/90"
title={session.title}
>
{session.title}
</span>
{session.subagent?.agentType ? (
<Badge
variant="outline"
className="h-5 shrink-0 border-border/70 bg-background px-1.5 py-0 text-[10px] font-medium"
>
{session.subagent.agentType}
</Badge>
) : null}
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionSubagents.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
draggable={false}
title={translate(
'auto.components.right.sidebar.AiVaultSessionSubagents.viewLog',
'View Log'
)}
onClick={(event) => {
event.stopPropagation()
void window.api.shell.openFilePath(session.filePath)
}}
className="shrink-0 text-muted-foreground"
>
<FileJson className="size-3.5" />
</Button>
</div>
)
}
@@ -1,5 +1,6 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useCallback, useMemo, useRef, useState } from 'react'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
@@ -45,6 +46,7 @@ export function AiVaultSessionVirtualList({
vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getSessionLiveState,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
@@ -68,6 +70,7 @@ export function AiVaultSessionVirtualList({
vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
@@ -194,6 +197,7 @@ export function AiVaultSessionVirtualList({
vaultScope={vaultScope}
buildResumeStartup={buildResumeStartup}
getOriginalPaneTarget={getOriginalPaneTarget}
getSessionLiveState={getSessionLiveState}
getWorktreeInfo={getWorktreeInfo}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
@@ -227,6 +231,7 @@ function AiVaultVirtualRow({
vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getSessionLiveState,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
@@ -252,6 +257,7 @@ function AiVaultVirtualRow({
vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
@@ -312,6 +318,7 @@ function AiVaultVirtualRow({
) : (
<VaultSessionRow
session={row.session}
liveState={getSessionLiveState(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
worktreeInfo={worktreeInfo}
vaultScope={vaultScope}
@@ -4,14 +4,19 @@ import { toast } from 'sonner'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
import {
findAiVaultSessionLiveState,
findOriginalAiVaultSessionPane
} from './ai-vault-original-pane'
export function useAiVaultOriginalPaneActions(): {
getOriginalPaneTarget: (
session: AiVaultSession
) => ReturnType<typeof findOriginalAiVaultSessionPane>
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
jumpToOriginalPane: (session: AiVaultSession) => void
jumpToWorktree: (worktreeId: string) => void
} {
@@ -30,6 +35,11 @@ export function useAiVaultOriginalPaneActions(): {
[originalPaneLookupState]
)
const getSessionLiveState = useCallback(
(session: AiVaultSession) => findAiVaultSessionLiveState(originalPaneLookupState, session),
[originalPaneLookupState]
)
const jumpToOriginalPane = useCallback((session: AiVaultSession): void => {
const target = findOriginalAiVaultSessionPane(useAppStore.getState(), session)
if (!target) {
@@ -70,5 +80,5 @@ export function useAiVaultOriginalPaneActions(): {
}
}, [])
return { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree }
return { getOriginalPaneTarget, getSessionLiveState, jumpToOriginalPane, jumpToWorktree }
}

Some files were not shown because too many files have changed in this diff Show More