diff --git a/.gitignore b/.gitignore index 24595e4c708..50c46ec9f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index da33ccb078d..c9cabcea838 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -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.') diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 848f3727653..eac27a90965 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -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 diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index d5d8396aeea..1041ca924f2 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -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) { diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index a56c45bc05f..199149edaa1 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -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 diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 4a68a25df28..15e1e616aa9 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -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'), '') + } } diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md new file mode 100644 index 00000000000..e5a6ac9aa3b --- /dev/null +++ b/docs/agent-status-over-wsl.md @@ -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/`. + +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\\...` 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 -- 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//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:" !== 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 -- ` 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`. diff --git a/package.json b/package.json index 65f4c0c7b34..3a31196cbec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prod-release-scan-1.4.131-rc2-output.md b/prod-release-scan-1.4.131-rc2-output.md deleted file mode 100644 index 4e5b0188ccd..00000000000 --- a/prod-release-scan-1.4.131-rc2-output.md +++ /dev/null @@ -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. diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index e704a8fe54f..e84cd3d0cf4 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -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) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index cd8f7fb4b7a..fabf508b141 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -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 diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 550af50bc42..79539599922 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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 + } + 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') diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index e68ec2a8dcb..0151f63755c 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -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 + ( + sftp: SFTPWrapper, + remoteHome: string, + options?: RemoteManagedHookInstallOptions + ) => Promise ] 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 { 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( diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts new file mode 100644 index 00000000000..d47dd400199 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -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 { + 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 = (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 = { + 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 = ( + method: string, + params: Record, + callback: SftpCallback, + pick: (result: { ok: true } & Wire) => Value + ): void => { + mux + .request(method, params) + .then((raw) => { + const result = raw as WslFsResult + 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, + callback: SftpCallback + ): void => { + call, undefined>(method, params, callback, () => undefined) + } + + const adapter = { + readFile(path: string, _encoding: unknown, callback: SftpCallback): 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 +} diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts new file mode 100644 index 00000000000..2c30739e25b --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -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 + /** Restart-stable, instance-unique key for the guest endpoint dir. */ + instanceKey: () => string | null + resolveBundle: typeof resolveWslHookRelayBundle + readBundle: (jsPath: string) => Buffer + listDistros: () => Promise + isDistroRunning: typeof isWslDistroRunning + spawnRelay: typeof spawnWslRelayProcess + runInstall: typeof runWslInstallProcess + waitForSentinel: typeof waitForWslRelaySentinel + ingest: (envelope: Record, 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[0], + connectionId + ), + installHooks: installRemoteManagedAgentHooks, + warn: (message) => console.warn(message), + transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS +} diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts new file mode 100644 index 00000000000..72ff3bb0c23 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -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 { + 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 +}): Promise { + 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, + 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) + return env +} diff --git a/src/main/agent-hooks/wsl-hook-relay-link.ts b/src/main/agent-hooks/wsl-hook-relay-link.ts new file mode 100644 index 00000000000..2e9fede9512 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-link.ts @@ -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, 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')) +} diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts new file mode 100644 index 00000000000..89b0987a582 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -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 { + const probe = createServer() + const port = await new Promise((resolve) => { + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + await new Promise((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[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): Promise => + 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) + } +) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts new file mode 100644 index 00000000000..7e341d408da --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -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((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((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((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((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((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): { + 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[] = [] + 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 => { + for (let i = 0; i < 25; i++) { + await Promise.resolve() + } + } + let resolveProbe: ((running: boolean) => void) | undefined + const isDistroRunning = vi.fn(() => new Promise((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(() => {})) + 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() + } + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts new file mode 100644 index 00000000000..0c70d3a48c9 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -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 + reinstallTimer?: ReturnType + lastInstallAt?: number +} + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +export class WslHookRelayManager { + private deps: WslHookRelayManagerDeps + private recovery: WslRelayRecovery + private states = new Map() + private defaultDistro: string | null = null + private disposed = false + private warnedBundleMissing = false + + constructor(deps: Partial = {}) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts new file mode 100644 index 00000000000..018d9101915 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -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 { + 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((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((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() + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.ts new file mode 100644 index 00000000000..71c8b4a22d5 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.ts @@ -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 + reinstallTimer?: ReturnType +} + +export type WslRelayRecoveryIo = { + isDistroRunning: (distro: string) => Promise + 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 { + 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) + } +} diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts new file mode 100644 index 00000000000..a8085485553 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -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 } + +function fakeChild(): FakeChild { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { write: ReturnType } + kill: ReturnType + } + 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 +): Promise { + 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((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)) + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts new file mode 100644 index 00000000000..fdabf70fe15 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts @@ -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 { + 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}` : ''}` +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c2b3bd344f0..c05261de31d 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -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( diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 3d7755bce77..a2280b29165 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -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 }) }) diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 9e09cf29e70..cc5ae37dad3 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -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 diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 0044929c3c8..07ed14298ed 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -141,6 +141,7 @@ function session( previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: `codex resume ${sessionId}` + resumeCommand: `codex resume ${sessionId}`, + subagent: null } } diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 2b75c758421..d02519eaff2 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -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( diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 5d7d1df939e..e88e7d9fc2e 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -121,7 +121,8 @@ export function finalizeSession( cwd: accumulator.cwd, platform, codexHome: options.codexHome - }) + }), + subagent: null } } diff --git a/src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts b/src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts new file mode 100644 index 00000000000..2f0fa7c389c --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts @@ -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 + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-claude-subagents.test.ts b/src/main/ai-vault/session-scanner-claude-subagents.test.ts new file mode 100644 index 00000000000..eb0f90d9bc9 --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-subagents.test.ts @@ -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 { + 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 { + 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: + '\nfinished\nrunning\n' + } + }, + { + type: 'user', + sessionId: 'parent-session', + timestamp: '2026-07-05T10:01:00.000Z', + message: { + role: 'user', + content: + '\nfinished\ncompleted\n' + } + }, + { + // 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: + '\ncrashed\nfailed\n' + }, + { + // 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 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 / before , + // pushing 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 = + '\nbigtask\n' + + 'toolu_01HB57dwUE6hQVK6Mh8jVFHA\n' + + '/private/tmp/claude-501/-Users-me-orca-workspaces-project/' + + 'parent-session/tasks/bigtask.output\n' + + 'completed\nAgent finished\n' + expect(notification.indexOf('')).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: + '\nquoted\ncompleted\n' + } + }, + { + // 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: \nquoted\nfailed\n' + } + } + ]) + 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 , 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 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: [] }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-claude-subagents.ts b/src/main/ai-vault/session-scanner-claude-subagents.ts new file mode 100644 index 00000000000..5c269d91cf3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-subagents.ts @@ -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 = '' +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>/ +const TASK_STATUS_PATTERN = /([a-z_]+)<\/status>/ + +// Statuses reported by parent-transcript records +// (background Tasks) and toolUseResult records (synchronous Tasks). +const TERMINAL_TASK_STATUSES: Record = { + 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 { + 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 (.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 + now: number + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + 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 +// whose 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> { + const statuses = new Map() + 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 marker follows / 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 { + 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-.meta.json` sidecar for every subagent transcript, +// carrying the Task tool's spawn `description` and its resolved `agentType`. +async function readSubagentMeta(transcriptPath: string): Promise { + 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 } + } +} diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts index 592ac2e9d72..f48d58f495a 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.test.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -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') + }) }) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 9bfade6e665..c176fb3c6e5 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -70,6 +70,8 @@ export async function walkSessionFiles( options: { extensions: Set 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 { diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index c72d0da8277..9ffe364c12f 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -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 ) diff --git a/src/main/ai-vault/session-scanner-recoverable-empty.test.ts b/src/main/ai-vault/session-scanner-recoverable-empty.test.ts index 21409fbc6d8..17c093a064c 100644 --- a/src/main/ai-vault/session-scanner-recoverable-empty.test.ts +++ b/src/main/ai-vault/session-scanner-recoverable-empty.test.ts @@ -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) + }) }) diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 8c03ec72e42..acf573f4964 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -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[] { - 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 `/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 }) ) } diff --git a/src/main/ai-vault/session-scanner-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-subagent-transcripts.ts index 303b9b968e1..0477f9dc229 100644 --- a/src/main/ai-vault/session-scanner-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-subagent-transcripts.ts @@ -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-.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 (…//.jsonl → …///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 { - 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: `//subagents/.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: `//subagents/agent-.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 /** diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 754a0f1113a..2c6259dd98e 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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( diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts new file mode 100644 index 00000000000..f16a6701126 --- /dev/null +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -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() + }) +}) diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index e7878777bff..00774a26ccb 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -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() +const inFlightWslCanonicalizations = new Set() + +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() + } +} diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 31e552df55c..66b2b65b077 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1184,9 +1184,25 @@ export class CodexHookService { return this.getStatus() } - async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - 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 { + 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) diff --git a/src/main/git/branch-rename.test.ts b/src/main/git/branch-rename.test.ts index 9b0917f7c9b..023bb542011 100644 --- a/src/main/git/branch-rename.test.ts +++ b/src/main/git/branch-rename.test.ts @@ -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') }) }) diff --git a/src/main/git/branch-rename.ts b/src/main/git/branch-rename.ts index 061f585f3b7..1237b82cf5a 100644 --- a/src/main/git/branch-rename.ts +++ b/src/main/git/branch-rename.ts @@ -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 { +export async function probeBranchUpstream(exec: GitExec): Promise { 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 } } diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts index 57b04831acc..df0392c3c10 100644 --- a/src/main/git/runner.test.ts +++ b/src/main/git/runner.test.ts @@ -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') + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index fca0db2ef65..a167b4eb83a 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -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) diff --git a/src/main/grok-accounts/status.test.ts b/src/main/grok-accounts/status.test.ts new file mode 100644 index 00000000000..fccf5dfd719 --- /dev/null +++ b/src/main/grok-accounts/status.test.ts @@ -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') + }) +}) diff --git a/src/main/grok-accounts/status.ts b/src/main/grok-accounts/status.ts new file mode 100644 index 00000000000..021483e4722 --- /dev/null +++ b/src/main/grok-accounts/status.ts @@ -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 + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 0351ecf1dde..1b91bf3696a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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. diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index ab1c7d77958..8d4ba30dbd5 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -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 } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 94a8de0c475..85e547da860 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -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 => + 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 { + // 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 `/../../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 } diff --git a/src/main/ipc/grok-accounts.ts b/src/main/ipc/grok-accounts.ts new file mode 100644 index 00000000000..8b0aa69f2a3 --- /dev/null +++ b/src/main/ipc/grok-accounts.ts @@ -0,0 +1,6 @@ +import { ipcMain } from 'electron' +import { getGrokAccountStatus } from '../grok-accounts/status' + +export function registerGrokAccountHandlers(): void { + ipcMain.handle('grokAccounts:getStatus', () => getGrokAccountStatus()) +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c6c0c6c5997..588df2d5704 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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?.() }) diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts index 3a299c5bda6..b53d87d5d83 100644 --- a/src/main/ipc/rate-limits.test.ts +++ b/src/main/ipc/rate-limits.test.ts @@ -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 } { +function makeService(): { + service: RateLimitService + refresh: ReturnType + refreshGrok: ReturnType +} { 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 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) }) }) diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index a28b7810263..b906ddce51f 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -24,4 +24,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { rateLimits.fetchInactiveCodexAccountsOnOpen() ) ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) + ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok()) } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index ad836b8e178..08af6977efa 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 2946204f517..dc2c219025a 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 540079a510e..01697bcc6fe 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -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 + + 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: [] }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 712f49f6d43..4f3a4698c76 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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) } diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index fbdc56bb5d5..f938dd815b0 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -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 diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 4e2a3963597..57a95df3a62 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -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 ) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 366c9d4256e..74c6611f1d0 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5ef81f071bd..3cd0fe52a72 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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, diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index b642e7661f6..f61272de8c3 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -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) { diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 9d7c36c6cfb..550295460ad 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -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 = { + 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 = { + 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 = { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' } + addOrcaWslInteropEnv(env) + expect(env.WSLENV).toBe('ORCA_WSL_HOOK_RELAY_VERSION/u') + }) }) diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 40a694a87ed..161621e8c0f 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -16,6 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void { export function addOrcaWslInteropEnv(env: Record): 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): 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' ] diff --git a/src/main/rate-limits/grok-auth.test.ts b/src/main/rate-limits/grok-auth.test.ts new file mode 100644 index 00000000000..793e3e7a694 --- /dev/null +++ b/src/main/rate-limits/grok-auth.test.ts @@ -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' + }) + }) +}) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts new file mode 100644 index 00000000000..508e0569bec --- /dev/null +++ b/src/main/rate-limits/grok-auth.ts @@ -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 +} diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts new file mode 100644 index 00000000000..f816d5c552f --- /dev/null +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -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() + }) +}) diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts new file mode 100644 index 00000000000..963a2fb1350 --- /dev/null +++ b/src/main/rate-limits/grok-fetcher.ts @@ -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 { + const headers: Record = { + 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 { + 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') + } +} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 082dcf282a9..166442ee0ae 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -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(): Deferred { return { promise, resolve } } +async function flushMicrotasks(times = 4): Promise { + 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() + 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') diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index d270c6773a8..cbfd8289b7e 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -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 | null = null private deferredStartupRefreshTimer: ReturnType | 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() 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 { + 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 { + 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 { + 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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7e4ad5d94dc..06285ac66e0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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[] = [] 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[] = [] 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[] = [ { 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[] = [] 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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a3eac592ed6..41bfdc75b7f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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((resolve, reject) => { - let proc: ReturnType + let proc: ReturnType try { - proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], { + proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], { cwd: trimmedDestination, stdio: ['ignore', 'ignore', 'pipe'] }) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 4c692264cc0..2af71e3abf1 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index b5991f669e4..99595922838 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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(), diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index 31736082ab8..f35ad4ac590 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -17,6 +17,8 @@ const { terminated = false emitStoppedOnStop = true emitReadyOnInit = HoistedMockWorker.emitReadyOnInit + emitExitOnTerminate = true + terminatePromise: Promise | null = null messages: WorkerMessage[] = [] private listeners = new Map void>>() @@ -68,8 +70,10 @@ const { terminate(): Promise { 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) + }) }) diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index 6974dd5945e..e8b8d5fadb4 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -22,6 +22,14 @@ export type SttEvent = export type SttEventSink = (event: SttEvent) => void +type StopInFlight = { + worker: Worker + owner: string + promise: Promise +} + +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() 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 + 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((resolve) => { - let settled = false - let timeout: ReturnType | null = null + private createStopPromise(worker: Worker, capturedSink: SttEventSink | null): Promise { + return new Promise((resolve) => { + let settled = false + let receivedStopped = false + let timeout: ReturnType | 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 { + 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 } } diff --git a/src/main/speech/stt-worker-model-config.ts b/src/main/speech/stt-worker-model-config.ts index 6a2a0e89dd9..de4687d9f46 100644 --- a/src/main/speech/stt-worker-model-config.ts +++ b/src/main/speech/stt-worker-model-config.ts @@ -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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3496e31a029..633ecc45be1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 + /** Lists the Task subagent transcripts of one session, on demand. */ + listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ onWindowFocused: (callback: () => void) => () => void } @@ -2876,6 +2884,7 @@ export type PreloadApi = { fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise refreshMiniMax: () => Promise + refreshGrok: () => Promise 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 + } ssh: { listTargets: () => Promise // Removed-target id → last known label, for showing a friendly host name on diff --git a/src/preload/index.ts b/src/preload/index.ts index 049acbb20ec..5cd262973fb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('aiVault:listSessions', args), + listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => + 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 => ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), + refreshGrok: (): Promise => 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 => ipcRenderer.invoke('grokAccounts:getStatus') + }, + ssh: { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 33f630d9c47..442052c1e96 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -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 => + 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) }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 769921f59c7..741a2a5ca01 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -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>() 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 { this.server = createServer((req, res) => this.handleRequest(req, res)) - await new Promise((resolve, reject) => { + return new Promise((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) } diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 8a98b9fa444..9e69339bfcd 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -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 | 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 diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 2d4bb2bb09b..a5ae6d3cb7a 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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 { 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 = '' diff --git a/src/relay/relay-command-env.test.ts b/src/relay/relay-command-env.test.ts index f2120c177cf..e9164fb86f7 100644 --- a/src/relay/relay-command-env.test.ts +++ b/src/relay/relay-command-env.test.ts @@ -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'])) + }) +}) diff --git a/src/relay/relay-command-env.ts b/src/relay/relay-command-env.ts index ad64703207d..3d4c22f7f30 100644 --- a/src/relay/relay-command-env.ts +++ b/src/relay/relay-command-env.ts @@ -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 } +} diff --git a/src/relay/wsl-agent-hook-relay.test.ts b/src/relay/wsl-agent-hook-relay.test.ts new file mode 100644 index 00000000000..c72f21462c8 --- /dev/null +++ b/src/relay/wsl-agent-hook-relay.test.ts @@ -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 { + const env: Record = {} + 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((resolve) => { + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + await new Promise((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((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((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((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((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() + }) +}) diff --git a/src/relay/wsl-agent-hook-relay.ts b/src/relay/wsl-agent-hook-relay.ts new file mode 100644 index 00000000000..3411cb690e6 --- /dev/null +++ b/src/relay/wsl-agent-hook-relay.ts @@ -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 { + 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 + ) + }) + + 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() diff --git a/src/relay/wsl-hook-fs-bridge.test.ts b/src/relay/wsl-hook-fs-bridge.test.ts new file mode 100644 index 00000000000..f656b4aa3bd --- /dev/null +++ b/src/relay/wsl-hook-fs-bridge.test.ts @@ -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 + const context: RequestContext = { clientId: 1, isStale: () => false } + + const call = async >( + method: string, + params: Record = {} + ): Promise> => { + const handler = handlers.get(method) + if (!handler) { + throw new Error(`no handler registered for ${method}`) + } + return (await handler(params, context)) as WslFsResult + } + + beforeEach(() => { + home = mkdtempSync(posix.join(tmpdir(), 'wsl-fs-home-')) + handlers = new Map() + // 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) + }) +}) diff --git a/src/relay/wsl-hook-fs-bridge.ts b/src/relay/wsl-hook-fs-bridge.ts new file mode 100644 index 00000000000..3ebb6047b1b --- /dev/null +++ b/src/relay/wsl-hook-fs-bridge.ts @@ -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 +): 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> => { + return { ok: true, home: homeRoot, ...linkStatus?.() } + } + ) + + dispatcher.onRequest( + WSL_HOOK_FS_METHODS.readFile, + async (params): Promise> => { + 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 => { + 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> => { + 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 => { + 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 => { + 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 => { + 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> => { + 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 => { + try { + await fs.mkdir(scoped(params.path)) + return { ok: true } + } catch (err) { + return failure(err) + } + }) +} diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index f3d801dc261..c039907c1c3 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -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 )} /> @@ -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' )} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index c03192f4054..20235973045 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -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(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} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index f5d6b4eb30c..d9ea283a0a5 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -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({ )} + + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index bf9f147e7b5..448be77c122 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -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({ Promise>() + +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 { + 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() + 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( + + ) + 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( + + ) + await act(async () => {}) + expect(listSubagentSessions).not.toHaveBeenCalled() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx new file mode 100644 index 00000000000..aa84e4bebe3 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx @@ -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 ( +
+
+ + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionSubagents.subagentsCount', + 'Subagents ({{value0}})', + { value0: subagents.sessions.length } + )} + +
+
+ {subagents.sessions.map((subagentSession) => ( + + ))} +
+
+ ) +} + +function useSubagentSessions(session: AiVaultSession): SubagentListState { + const [state, setState] = useState({ 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 = { + 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 ( +
+ {dotState ? ( + // Why: a plain inline span would baseline-align the dot; flex keeps it + // vertically centered with the row text. + + + + ) : null} + + {session.title} + + {session.subagent?.agentType ? ( + + {session.subagent.agentType} + + ) : null} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionSubagents.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 505d793d2df..ce9416ac73a 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -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({ ) : ( ReturnType + 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 } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts index e5fd9c270e7..20334e03fe8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -3,7 +3,10 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane' +import { + findAiVaultSessionLiveState, + findOriginalAiVaultSessionPane +} from './ai-vault-original-pane' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' @@ -27,7 +30,8 @@ const baseSession: AiVaultSession = { previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: "codex resume 'session-1'" + resumeCommand: "codex resume 'session-1'", + subagent: null } function makeTab(id = 'tab-1', worktreeId = 'wt-1') { @@ -214,3 +218,53 @@ describe('findOriginalAiVaultSessionPane', () => { expect(target?.leafId).toBe(LEAF_ID) }) }) + +describe('findAiVaultSessionLiveState', () => { + it('returns the live entry state when the provider session id matches', () => { + const state = makeState({ + agentStatusByPaneKey: { [makeEntry().paneKey]: makeEntry({ state: 'blocked' }) } + }) + + expect(findAiVaultSessionLiveState(state, baseSession)).toBe('blocked') + }) + + it('falls back to a prompt match only when it is unambiguous', () => { + const matching = makeEntry({ + providerSession: undefined, + prompt: baseSession.title, + state: 'working' + }) + expect( + findAiVaultSessionLiveState( + makeState({ agentStatusByPaneKey: { [matching.paneKey]: matching } }), + baseSession + ) + ).toBe('working') + + const otherPaneKey = makePaneKey('tab-1', OTHER_LEAF_ID) + const ambiguous = makeState({ + agentStatusByPaneKey: { + [matching.paneKey]: matching, + [otherPaneKey]: makeEntry({ + providerSession: undefined, + prompt: baseSession.title, + paneKey: otherPaneKey, + state: 'waiting' + }) + } + }) + expect(findAiVaultSessionLiveState(ambiguous, baseSession)).toBeNull() + }) + + it('returns null when no live pane matches the session', () => { + const state = makeState({ + agentStatusByPaneKey: { + [makeEntry().paneKey]: makeEntry({ + providerSession: { key: 'session_id', id: 'other-session' } + }) + } + }) + + expect(findAiVaultSessionLiveState(state, baseSession)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts index a878789891c..48f76ac29e2 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts @@ -1,5 +1,6 @@ import type { AppState } from '@/store/types' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' +import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' @@ -171,6 +172,32 @@ function resolveOriginalPaneTarget(args: { return { paneKey, worktreeId, tabId: legacy.tabId, leafId } } +/** + * The hook-reported live state of the agent currently running this session, + * or null when the session is not live in any pane. Matches by provider + * session id first; falls back to a prompt match only when it is unambiguous. + */ +export function findAiVaultSessionLiveState( + state: Pick, + session: AiVaultSession +): AgentStatusState | null { + const promptMatchedStates: AgentStatusState[] = [] + + for (const entry of Object.values(state.agentStatusByPaneKey)) { + if (!agentMatches(session, entry.agentType)) { + continue + } + if (providerSessionMatches(session, entry.providerSession?.id)) { + return entry.state + } + if (entry.providerSession === undefined && promptsMatchSession(session, entry)) { + promptMatchedStates.push(entry.state) + } + } + + return promptMatchedStates.length === 1 ? promptMatchedStates[0] : null +} + export function findOriginalAiVaultSessionPane( state: OriginalPaneState, session: AiVaultSession diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index bd2c3a080ad..35d55cd42b9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -4,6 +4,7 @@ import { latestSessionConversationTurn, recentSessionConversationTurns, sessionDetailConversationTurns, + sessionModelLabel, sessionPreviewSearchText } from './ai-vault-session-display' @@ -31,7 +32,8 @@ const baseSession: AiVaultSession = { ], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: "cd '/Users/ada/repo/app' && codex resume 'session-1'" + resumeCommand: "cd '/Users/ada/repo/app' && codex resume 'session-1'", + subagent: null } describe('ai vault session display', () => { @@ -92,4 +94,9 @@ describe('ai vault session display', () => { 'Added a regression test' ]) }) + + it('labels the session model only when the transcript recorded one', () => { + expect(sessionModelLabel(baseSession)).toBe('gpt-5.5') + expect(sessionModelLabel({ ...baseSession, model: null })).toBeNull() + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts index a4078616810..a7ab45e936e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts @@ -80,6 +80,10 @@ function normalizeSessionDisplayText(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase() } +export function sessionModelLabel(session: AiVaultSession): string | null { + return session.model || null +} + export function sessionPreviewSearchText(session: AiVaultSession): string { return displayableSessionPreviewMessages(session) .map((message) => message.text) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 3f1012d61bc..63139bdcd71 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -32,7 +32,8 @@ const baseSession: AiVaultSession = { previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: "cd '/Users/ada/repo/app' && claude --resume 'session-1'" + resumeCommand: "cd '/Users/ada/repo/app' && claude --resume 'session-1'", + subagent: null } describe('filterAiVaultSessions', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index b73e2afd877..d622af80803 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -23,7 +23,8 @@ const baseSession: AiVaultSession = { previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'" + resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'", + subagent: null } describe('toAiVaultProjectKey', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx index c356cb13431..aacc717a341 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -1,7 +1,9 @@ import type React from 'react' import { Badge } from '@/components/ui/badge' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { AgentStateDot } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' +import type { AgentStatusState } from '../../../../shared/agent-status-types' import { useRepoById } from '@/store/selectors' import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' @@ -12,6 +14,7 @@ import { } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { SessionTime } from './AiVaultSessionDetails' +import { sessionModelLabel } from './ai-vault-session-display' import { agentLabel } from './ai-vault-session-filters' import { aiVaultWorktreeStatusLabel, @@ -26,22 +29,28 @@ export function getSessionDetailsId(sessionId: string): string { export function SessionMetadata({ session, + liveState, updatedAt, worktreeInfo, vaultScope }: { session: AiVaultSession + liveState: AgentStatusState | null updatedAt: string worktreeInfo: AiVaultSessionWorktreeInfo | null vaultScope: AiVaultScope }) { + const modelLabel = sessionModelLabel(session) return (
- {agentLabel(session.agent)} + {/* Why: 'done' is the resting state of every finished pane — badging it + would mark most rows; only live attention states earn a dot. */} + {liveState && liveState !== 'done' ? : null} + {agentLabel(session.agent)} {translate( 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', @@ -49,6 +58,23 @@ export function SessionMetadata({ { value0: session.messageCount } )} + {session.subagentTranscriptCount > 0 ? ( + <> + · + + {session.subagentTranscriptCount === 1 + ? translate( + 'auto.components.right.sidebar.AiVaultSessionRow.subagentCountSingular', + '1 subagent' + ) + : translate( + 'auto.components.right.sidebar.AiVaultSessionRow.subagentCountPlural', + '{{value0}} subagents', + { value0: session.subagentTranscriptCount } + )} + + + ) : null} {isAiVaultSessionRecoverableEmpty(session) ? ( <> · @@ -62,6 +88,14 @@ export function SessionMetadata({ ) : null} · + {modelLabel ? ( + <> + · + + {modelLabel} + + + ) : null}
{shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 0b3e2c8d77e..7f5f0d07b0c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -33,7 +33,8 @@ const baseSession: AiVaultSession = { previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: "codex resume 'session-1'" + resumeCommand: "codex resume 'session-1'", + subagent: null } function makeWorktree(overrides: Partial = {}): Worktree { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 7719dfd9ec4..dbd4b752e6b 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -43,10 +43,12 @@ import { getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, getAccountsLocationSearchEntries, + getAccountsGrokSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' +import { GrokAccountsSection } from './GrokAccountsSection' import { SearchableSetting } from './SearchableSetting' import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' import { matchesSettingsSearch } from './settings-search' @@ -1796,6 +1798,9 @@ export function AccountsPane({
+ ) : null, + matchesSettingsSearch(searchQuery, getAccountsGrokSearchEntries()) ? ( + ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 2173196651a..97d53297e0a 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -43,7 +43,8 @@ function recordStatusBarToggleInteraction( id === 'gemini' || id === 'opencode-go' || id === 'kimi' || - id === 'minimax' + id === 'minimax' || + id === 'grok' ) { recordFeatureInteraction('usage-tracking') } diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx new file mode 100644 index 00000000000..a5eb0ca8d99 --- /dev/null +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useState } from 'react' +import { ExternalLink, Loader2, RefreshCw, ShieldCheck } from 'lucide-react' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '../../store' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import type { GrokAccountStatus } from '../../../../shared/rate-limit-types' +import { SearchableSetting } from './SearchableSetting' +const GROK_CLI_DOCS_URL = 'https://docs.x.ai/build/overview' + +export function GrokAccountsSection(): React.JSX.Element { + const refreshGrokRateLimits = useAppStore((s) => s.refreshGrokRateLimits) + const grokUsage = useAppStore((s) => s.rateLimits.grok) + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const loadStatus = useCallback(async (): Promise => { + try { + const next = await window.api.grokAccounts.getStatus() + setStatus(next) + } catch (error) { + console.error('Failed to load Grok account status:', error) + setStatus({ + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: error instanceof Error ? error.message : 'Unable to read Grok sign-in' + }) + } finally { + setLoading(false) + } + }, []) + + // Why: after a background usage fetch, sign-in state may change — reload status then. + useEffect(() => { + void loadStatus() + }, [loadStatus, grokUsage?.updatedAt]) + + const handleRefreshUsage = async (): Promise => { + setRefreshing(true) + try { + await refreshGrokRateLimits() + await loadStatus() + } finally { + setRefreshing(false) + } + } + + const signedIn = status?.signedIn === true + const tokenFresh = status?.tokenFresh === true + + return ( +
+
+
+

+ + {translate('auto.components.settings.GrokAccountsSection.a1b2c3d4e5', 'Grok (xAI)')} +

+

+ {translate( + 'auto.components.settings.GrokAccountsSection.f6e5d4c3b2', + 'Shows weekly credit usage from your Grok CLI sign-in (session file ~/.grok/auth.json).' + )} +

+
+ + {translate('auto.components.settings.GrokAccountsSection.0d8e77bc40', 'Grok CLI docs')} + + +
+ +
+ +
+ {loading ? ( +

+ {translate('auto.components.settings.GrokAccountsSection.ad47a33f72', 'Loading…')} +

+ ) : signedIn ? ( + <> +

+ {status?.email ?? + translate('auto.components.settings.GrokAccountsSection.b2c3d4e5f6', 'Signed in')} +

+

+ {tokenFresh + ? translate( + 'auto.components.settings.GrokAccountsSection.c3d4e5f6a7', + 'Signed in. Orca only reads that file on disk — run grok login again if usage fails.' + ) + : translate( + 'auto.components.settings.GrokAccountsSection.d4e5f6a7b8', + 'Session expired — run grok login in a terminal to refresh.' + )} +

+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.GrokAccountsSection.e5f6a7b8c9', + 'Not signed in to Grok CLI' + )} +

+

+ {translate( + 'auto.components.settings.GrokAccountsSection.f6a7b8c9d0', + 'In a terminal, run grok login, then click Refresh usage here.' + )} +

+ + )} + {status?.error ?

{status.error}

: null} +
+ +
+ + {grokUsage?.weekly ? ( + +
+ + {Math.round(grokUsage.weekly.usedPercent)}% + + {grokUsage.weekly.resetDescription ? ( + + {translate( + 'auto.components.settings.GrokAccountsSection.c6d1a8f4e2', + 'Resets {{when}}', + { when: grokUsage.weekly.resetDescription } + )} + + ) : null} + {grokUsage.usageMetadata?.authProvenance ? ( + + {grokUsage.usageMetadata.authProvenance} + + ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index bfe82c9d783..3b2fc066355 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1490,7 +1490,7 @@ function Settings(): React.JSX.Element { title={translate('auto.components.settings.Settings.954a8f5aef', 'Stats & Usage')} description={translate( 'auto.components.settings.Settings.8acf3f22e0', - 'Orca stats plus Claude, Codex, and OpenCode usage analytics.' + 'Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.' )} searchEntries={getSectionSearchEntries('stats')} > diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx index 80479cf2a36..211424b09be 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx @@ -225,6 +225,12 @@ describe('TerminalSettingsPreview terminal lifecycle', () => { expect(terminal.dispose).toHaveBeenCalledOnce() }) + it('does not pass a persisted sub-minimum line height to xterm', () => { + renderPreview(makeSettings({ terminalLineHeight: 0.85 })) + + expect(mockXterm.instances[0]?.options.lineHeight).toBe(1) + }) + it('disposes the ligatures addon before disposing the terminal', () => { mockLigaturesAddon.enabled = true diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index 8d2a74a51ca..6e94e4a3eab 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -10,6 +10,7 @@ import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal- import { clampNumber, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' +import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { PREVIEW_BUFFER } from './terminal-preview-content' import { SettingsSwitch } from './SettingsFormControls' import type { GlobalSettings } from '../../../../shared/types' @@ -79,6 +80,7 @@ export function TerminalSettingsPreview({ const skipInitialThemeRewriteRef = useRef(false) const effectiveFontFamily = previewFontFamily || settings.terminalFontFamily + const terminalLineHeight = normalizeTerminalLineHeight(settings.terminalLineHeight) // Why: lazy-init from the active app theme so the toggle starts in the // user's current mode. After init the toggle is independent — flipping @@ -162,7 +164,7 @@ export function TerminalSettingsPreview({ fontFamily: buildFontFamily(effectiveFontFamily), fontWeight: weights.fontWeight, fontWeightBold: weights.fontWeightBold, - lineHeight: settings.terminalLineHeight, + lineHeight: terminalLineHeight, theme: composedTheme ?? undefined, allowTransparency: settings.terminalBackgroundOpacity !== undefined && settings.terminalBackgroundOpacity < 1, @@ -208,7 +210,7 @@ export function TerminalSettingsPreview({ terminal.options.fontFamily = buildFontFamily(effectiveFontFamily) terminal.options.fontWeight = weights.fontWeight terminal.options.fontWeightBold = weights.fontWeightBold - terminal.options.lineHeight = settings.terminalLineHeight + terminal.options.lineHeight = terminalLineHeight terminal.options.cursorStyle = settings.terminalCursorStyle // Why: see constructor — mirror so the unfocused cursor reflects the // user's chosen shape (xterm defaults inactive to 'outline'). @@ -218,7 +220,7 @@ export function TerminalSettingsPreview({ settings.terminalFontSize, effectiveFontFamily, settings.terminalFontWeight, - settings.terminalLineHeight, + terminalLineHeight, settings.terminalCursorStyle, settings.terminalCursorBlink ]) diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 7f0d68a8387..6ff07366bc9 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -191,11 +191,33 @@ export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsGrokSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.f4a8c2e1b7', 'Grok (xAI) Usage'), + description: translate( + 'auto.components.settings.accounts.search.e3b7d1f9a2', + 'OAuth sign-in via Grok CLI (grok login) for weekly credit usage.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.d2c6a0e8f1', 'grok'), + ...translateSearchKeyword('auto.components.settings.accounts.search.c1b5f9d7e0', 'xai'), + ...translateSearchKeyword('auto.components.settings.accounts.search.b0a4e8c6d9', 'oauth'), + ...translateSearchKeyword('auto.components.settings.accounts.search.a9f3d7b5c8', 'login'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.e949b08ffb', + 'rate limit' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), ...getAccountsGeminiSearchEntries(), ...getAccountsOpencodeSearchEntries(), - ...getAccountsMiniMaxSearchEntries() + ...getAccountsMiniMaxSearchEntries(), + ...getAccountsGrokSearchEntries() ]) diff --git a/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts new file mode 100644 index 00000000000..3ff4295238e --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts @@ -0,0 +1,37 @@ +import type { StatusBarItem } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getGrokStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'grok', + title: translate('auto.components.settings.appearance.search.f8e2a1c4b6', 'Grok Usage'), + description: translate( + 'auto.components.settings.appearance.search.e7d1b0f3a5', + 'Show Grok weekly credit usage from Grok CLI OAuth.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.d6c0a9e2f4', 'grok'), + ...translateSearchKeyword('auto.components.settings.appearance.search.c5b9f8d1e3', 'xai'), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.grokToggleDescription', + 'Show Grok subscription credit usage when signed in via Grok CLI.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 673deda4b54..9044c084fa6 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -2,6 +2,7 @@ import type { StatusBarItem } from '../../../../shared/types' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' +import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( (): readonly { @@ -196,6 +197,7 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show MiniMax subscription usage for the active workspace.' ) }, + getGrokStatusBarToggleSearchEntry(), { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a1542323e62..84f5ab77838 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -100,7 +100,6 @@ import { type RenderRow } from './worktree-list-virtual-rows' import { - getElementCenteringScrollPadding, revealElementInScrollContainer, WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from './worktree-sidebar-reveal' @@ -288,7 +287,6 @@ import { import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' export { - getCenteringScrollPadding, getScrollTopToRevealBounds, WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from './worktree-sidebar-reveal' @@ -1322,11 +1320,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [worktreeDragState, setWorktreeDragState] = useState( WORKTREE_ROW_DRAG_INITIAL_STATE ) - const [centeringScrollPadding, setCenteringScrollPadding] = useState<{ - targetKey: string - start: number - end: number - } | null>(null) const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) @@ -1976,8 +1969,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), overscan: 10, gap: 6, - paddingStart: centeringScrollPadding?.start ?? 0, - paddingEnd: centeringScrollPadding?.end ?? 0, // Why: the active sticky group header is rendered inside the virtual list, // so TanStack's scroll math needs the same top inset as the exact DOM reveal. scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, @@ -2129,30 +2120,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } } const optionId = getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId) - const centeringTargetKey = `worktree:${optionId ?? pendingRevealWorktree.worktreeId}` - if (centeringScrollPadding && centeringScrollPadding.targetKey !== centeringTargetKey) { - setCenteringScrollPadding(null) - retryExactRevealOnNextFrame() - return - } const option = optionId ? document.getElementById(optionId) : getMountedWorktreeOptions(pendingRevealWorktree.worktreeId, container)[0] const mountedOption = container && option && container.contains(option) ? option : null - if (container && mountedOption) { - const additionalPadding = getElementCenteringScrollPadding(container, mountedOption) - if (additionalPadding && (additionalPadding.start > 0 || additionalPadding.end > 0)) { - // Why: the first and last virtual rows need temporary boundary space - // or the browser clamps their centered position to the list edge. - setCenteringScrollPadding({ - targetKey: centeringTargetKey, - start: (centeringScrollPadding?.start ?? 0) + additionalPadding.start, - end: (centeringScrollPadding?.end ?? 0) + additionalPadding.end - }) - retryExactRevealOnNextFrame() - return - } - } const revealedOption = container && mountedOption && revealElementInScrollContainer(container, mountedOption) ? mountedOption @@ -2228,7 +2199,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pendingRevealRetryTick, flashRevealedRow, setRenamingWorktreeId, - centeringScrollPadding, schedulePendingRevealFrame, cancelPendingRevealFrames ]) @@ -2312,14 +2282,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } const container = scrollRef.current - // Why: only clear stale boundary padding here. While a worktree reveal is - // still pending, clearing would wipe the padding it just accumulated and - // its boundary target could exhaust retries without ever centering. - if (centeringScrollPadding && !pendingRevealWorktree) { - setCenteringScrollPadding(null) - retryExactRevealOnNextFrame() - return - } const revealedElement = container ? revealMountedSidebarRowElement(container, pendingRevealSidebarRow.rowKey) : null @@ -2345,7 +2307,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } }, [ pendingRevealSidebarRow, - pendingRevealWorktree, repoMap, projectGroups, projectGrouping, @@ -2357,7 +2318,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pendingRevealRetryTick, flashRevealedRow, clearPendingRevealSidebarRow, - centeringScrollPadding, schedulePendingRevealFrame, cancelPendingRevealFrames ]) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index e5a678ad82b..dc08ea6f271 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -41,6 +41,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string { return 'blocked' case 'interrupted': return 'interrupted' + case 'failed': + return 'failed' case 'working': return 'working' case 'done': diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index f9a5a5f62ce..aa71d479445 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { countRecordKeysByReference, - getCenteringScrollPadding, getScrollTopToRevealBounds, resolvePendingSidebarReveal, WORKTREE_SIDEBAR_REVEAL_TOP_INSET, shouldAdjustWorktreeSidebarMeasuredRowScroll } from './WorktreeList' +import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { extractWorktreeVirtualRowIndexes, estimateRenderRowSize, @@ -125,21 +125,6 @@ describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => { }) describe('getScrollTopToRevealBounds', () => { - it('requests leading space when the first target would be clamped above center', () => { - const container = makeScrollContainer(0, 400) - - expect( - getCenteringScrollPadding( - container, - { - start: 34, - end: 74 - }, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET - ) - ).toEqual({ start: 163, end: 0 }) - }) - it('centers a fully visible target', () => { const container = makeScrollContainer(100, 400) @@ -170,19 +155,22 @@ describe('getScrollTopToRevealBounds', () => { ).toBe(136) }) - it('requests trailing space when the last target would be clamped below center', () => { - const container = makeScrollContainer(100, 400, 500) + it('clamps a boundary target to the list edge instead of padding the list', () => { + // Regression (#8019 follow-up): centering first/last rows via temporary + // paddingStart/paddingEnd left a permanent phantom gap in the sidebar once + // the reveal finished, since nothing cleared the padding afterwards. + const container = { + scrollTop: 100, + clientHeight: 200, + contains: () => true, + getBoundingClientRect: () => ({ top: 0, bottom: 200 }) + } as unknown as HTMLElement + const firstRow = { + getBoundingClientRect: () => ({ top: -40, bottom: 20 }) + } as unknown as Element - expect( - getCenteringScrollPadding( - container, - { - start: 430, - end: 470 - }, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET - ) - ).toEqual({ start: 0, end: 133 }) + expect(revealElementInScrollContainer(container, firstRow)).toBe(true) + expect(container.scrollTop).toBe(0) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts index 850fc45704d..e2ec2cfaffd 100644 --- a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts +++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts @@ -9,8 +9,8 @@ describe('getScrollTopToRevealBounds', () => { }) as HTMLElement it('centers a mounted current workspace card that starts above the viewport', () => { - // Raw result may be negative; boundary reveals convert the deficit into - // temporary paddingStart instead of clamping (see getCenteringScrollPadding). + // Raw result may be negative; revealElementInScrollContainer clamps it to + // the list edge so boundary reveals never pad the list. expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(-10) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts index 765efc2eabc..56586682a50 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-reveal.ts @@ -9,11 +9,6 @@ type SidebarRevealBounds = { end: number } -type SidebarCenteringScrollPadding = { - start: number - end: number -} - function getElementScrollBounds(container: HTMLElement, element: Element): SidebarRevealBounds { const containerRect = container.getBoundingClientRect() const elementRect = element.getBoundingClientRect() @@ -36,30 +31,6 @@ export function getScrollTopToRevealBounds( return targetCenter - viewportCenterOffset } -export function getCenteringScrollPadding( - container: HTMLElement, - bounds: SidebarRevealBounds, - topInset = 0 -): SidebarCenteringScrollPadding { - const desiredScrollTop = getScrollTopToRevealBounds(container, bounds, topInset) - const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) - return { - start: Math.ceil(Math.max(0, -desiredScrollTop)), - end: Math.ceil(Math.max(0, desiredScrollTop - maxScrollTop)) - } -} - -export function getElementCenteringScrollPadding( - container: HTMLElement, - element: Element, - topInset = WORKTREE_SIDEBAR_REVEAL_TOP_INSET -): SidebarCenteringScrollPadding | null { - if (!container.contains(element)) { - return null - } - return getCenteringScrollPadding(container, getElementScrollBounds(container, element), topInset) -} - export function revealElementInScrollContainer(container: HTMLElement, element: Element): boolean { if (!container.contains(element)) { return false @@ -70,7 +41,8 @@ export function revealElementInScrollContainer(container: HTMLElement, element: WORKTREE_SIDEBAR_REVEAL_TOP_INSET ) // Why: sidebar reveal is a focus handoff, so reposition immediately instead - // of making the user track an animated list. + // of making the user track an animated list. Boundary rows clamp to the list + // edge — padding the list so they can center leaves a phantom gap behind. container.scrollTop = Math.max(0, nextScrollTop) return true } diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx new file mode 100644 index 00000000000..6e942363112 --- /dev/null +++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../../store' + +const storeMocks = vi.hoisted(() => ({ + refreshGrokRateLimits: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + recordFeatureInteraction: vi.fn() +})) + +const mockStoreState = { + rateLimits: { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + minimax: null, + grok: { + provider: 'grok', + session: null, + weekly: { + usedPercent: 42, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: 'Tue' + }, + updatedAt: 1, + error: null, + status: 'ok' + }, + minimaxCookieConfigured: false, + grokAuthConfigured: true, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + }, + refreshGrokRateLimits: storeMocks.refreshGrokRateLimits, + openSettingsPage: storeMocks.openSettingsPage, + openSettingsTarget: storeMocks.openSettingsTarget, + recordFeatureInteraction: storeMocks.recordFeatureInteraction +} satisfies Partial + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: Partial) => unknown) => selector(mockStoreState), + { + getState: () => mockStoreState + } + ) +})) + +vi.mock('../ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [token, value]) => text.replace(`{{${token}}}`, value), + fallback + ) + : fallback +})) + +import { GrokUsagePane } from './GrokUsagePane' + +describe('GrokUsagePane', () => { + beforeEach(() => { + storeMocks.refreshGrokRateLimits.mockResolvedValue(undefined) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('does not refresh all providers just from opening the Grok tab', () => { + render() + + expect(screen.getByTestId('grok-usage-pane')).toBeInTheDocument() + expect(storeMocks.refreshGrokRateLimits).not.toHaveBeenCalled() + }) + + it('refreshes usage only from the explicit refresh button', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Refresh Grok usage' })) + + expect(storeMocks.refreshGrokRateLimits).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/stats/GrokUsagePane.tsx b/src/renderer/src/components/stats/GrokUsagePane.tsx new file mode 100644 index 00000000000..8e51049e3d4 --- /dev/null +++ b/src/renderer/src/components/stats/GrokUsagePane.tsx @@ -0,0 +1,137 @@ +import { CalendarClock, ExternalLink, RefreshCw, Sparkles } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { StatCard } from './StatCard' +import { formatUpdatedAt } from './usage-formatters' + +export function GrokUsagePane(): React.JSX.Element { + const grok = useAppStore((s) => s.rateLimits.grok) + const grokAuthConfigured = useAppStore((s) => s.rateLimits.grokAuthConfigured) + const refreshGrokRateLimits = useAppStore((s) => s.refreshGrokRateLimits) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + + const openGrokAccounts = (): void => { + openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-grok' }) + openSettingsPage() + } + + const paneTitle = translate('auto.components.stats.GrokUsagePane.g8h9i0j1k2', 'Grok usage') + + if (!grokAuthConfigured) { + return ( +
+
+
+

{paneTitle}

+

+ {translate( + 'auto.components.stats.GrokUsagePane.b2d3e4f5c6', + 'Weekly subscription credits from Grok CLI OAuth (~/.grok/auth.json). Same source as the status bar.' + )} +

+
+
+
+ +
+
+ ) + } + + const weeklyPercent = + grok?.weekly && typeof grok.weekly.usedPercent === 'number' + ? Math.round(grok.weekly.usedPercent) + : null + const isFetching = grok?.status === 'fetching' + + return ( +
+
+
+

{paneTitle}

+

+ {formatUpdatedAt(grok?.updatedAt ?? null)} + {grok?.error + ? translate('auto.components.stats.GrokUsagePane.h9i0j1k2l3', ' • {{value0}}', { + value0: grok.error + }) + : ''} +

+
+
+ + + + + + + {translate('auto.components.stats.GrokUsagePane.d4f5a6b7c8', 'Refresh')} + + + +
+
+ +
+ } + /> + } + /> +
+ + {grok?.usageMetadata?.authProvenance ? ( +

{grok.usageMetadata.authProvenance}

+ ) : null} + +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 5746d00f8a4..e21437bb200 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -4,6 +4,7 @@ import { useAppStore } from '../../store' import { StatCard } from './StatCard' import { ClaudeUsagePane } from './ClaudeUsagePane' import { CodexUsagePane } from './CodexUsagePane' +import { GrokUsagePane } from './GrokUsagePane' import { OpenCodeUsagePane } from './OpenCodeUsagePane' import { UsageOverviewPane } from './UsageOverviewPane' import { Button } from '../ui/button' @@ -45,7 +46,7 @@ function formatTrackingSince(timestamp: number | null): string { return `Tracking since ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` } -type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' +type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' | 'grok' const USAGE_ANALYTICS_OPTIONS = [ { @@ -71,6 +72,12 @@ const USAGE_ANALYTICS_OPTIONS = [ get label() { return translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode') } + }, + { + id: 'grok', + get label() { + return translate('auto.components.stats.StatsPane.grokUsageTab', 'Grok') + } } ] as const satisfies readonly { id: UsageTab; label: string }[] @@ -193,8 +200,10 @@ export function StatsPane(): React.JSX.Element { ) : activeUsageTab === 'codex' ? ( - ) : ( + ) : activeUsageTab === 'opencode' ? ( + ) : ( + )}
diff --git a/src/renderer/src/components/stats/stats-search.ts b/src/renderer/src/components/stats/stats-search.ts index 797c5fba3d6..08bb306b634 100644 --- a/src/renderer/src/components/stats/stats-search.ts +++ b/src/renderer/src/components/stats/stats-search.ts @@ -6,7 +6,7 @@ export const getStatsPaneSearchEntries = createLocalizedCatalog(() => [ title: translate('auto.components.stats.stats.search.cb2430ae6a', 'Stats & Usage'), description: translate( 'auto.components.stats.stats.search.26bb901fcd', - 'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.' + 'Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.' ), keywords: [ translate('auto.components.stats.stats.search.372debfac0', 'stats'), @@ -20,7 +20,12 @@ export const getStatsPaneSearchEntries = createLocalizedCatalog(() => [ translate('auto.components.stats.stats.search.b77826fca3', 'codex'), translate('auto.components.stats.stats.search.6953af58e6', 'opencode'), translate('auto.components.stats.stats.search.eaf251e183', 'tokens'), - translate('auto.components.stats.stats.search.cb6a9f0334', 'cache') + translate('auto.components.stats.stats.search.cb6a9f0334', 'cache'), + translate('auto.components.stats.stats.search.f8a1b2c3d4', 'grok'), + translate('auto.components.stats.stats.search.e7f0a1b2c3', 'subscription'), + translate('auto.components.stats.stats.search.d6e9f0a1b2', 'credits'), + translate('auto.components.stats.stats.search.a3b6c7d8e9', 'grok usage'), + translate('auto.components.stats.stats.search.9f2a3b4c5d', 'xai') ] } ]) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index d3dd012135e..3db1b663f24 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1762,7 +1762,9 @@ export function ProviderDetailsMenu({ ? 'K' : provider.provider === 'minimax' ? 'M' - : 'X'} + : provider.provider === 'grok' + ? 'R' + : 'X'} ) : ( @@ -1908,7 +1910,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi, minimax } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, minimax, grok } = rateLimits // Why: a provider earns a bar from either a usable live snapshot or durable // setup in Settings. The durable path keeps account switchers visible while @@ -1917,12 +1919,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele // bars when the agent isn't installed on PATH. // Why: thread the cookie durability flag from RateLimitState so the // MiniMax bar stays visible after a reload and between snapshot refreshes. - const usageSettings = { ...settings, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured } + const usageSettings = { + ...settings, + minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, + grokAuthConfigured: rateLimits.grokAuthConfigured + } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) + const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -1942,6 +1949,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele // Why: MiniMax is a cookie-auth provider, not a CLI on PATH, so detection-gating // doesn't apply (same rationale as OpenCode Go below). const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') + const showGrok = + visibleGrok !== null && + statusBarItems.includes('grok') && + isStatusBarItemAvailable('grok', detectedAgentIds) // Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so // detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) @@ -1958,13 +1969,14 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele showOpencodeGo || showKimi || showMiniMax || + showGrok || showResourceUsage // Why: a brand-new user with no provider configured would otherwise see an // empty left side of the status bar and wonder what's missing. Settings are // included because managed accounts are durable even when live usage // snapshots are still hydrating or unavailable after an update. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, minimax }, + { claude, codex, gemini, opencodeGo, kimi, minimax, grok }, usageSettings ) // Why: the teaching CTA is a one-time nudge — once the user hides it, keep it @@ -1976,7 +1988,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || kimi?.status === 'fetching' || - minimax?.status === 'fetching' + minimax?.status === 'fetching' || + grok?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -2065,6 +2078,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} + {showGrok && ( + + )} )} {anyVisible && !isEmptyUsageState && ( @@ -2218,6 +2242,18 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')} + {isStatusBarItemAvailable('grok', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('grok') + }} + > + + {translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')} + + )} { diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index edd6cd97cc6..b888a52fb05 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -18,17 +18,20 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', null)).toBe(true) expect(isStatusBarItemAvailable('codex', null)).toBe(true) expect(isStatusBarItemAvailable('gemini', null)).toBe(true) + expect(isStatusBarItemAvailable('grok', null)).toBe(true) }) it('hides CLI items not detected on PATH', () => { expect(isStatusBarItemAvailable('claude', [])).toBe(false) expect(isStatusBarItemAvailable('codex', ['claude'])).toBe(false) expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) + expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) }) it('shows CLI items detected on PATH', () => { expect(isStatusBarItemAvailable('claude', ['claude'])).toBe(true) expect(isStatusBarItemAvailable('codex', ['codex', 'claude'])).toBe(true) expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) + expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index fbc63b88c5d..805d7079cee 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -6,7 +6,13 @@ import type { StatusBarItem, TuiAgent } from '../../../../shared/types' // PATH detection reports the agent as missing. Pre-detection (null) keeps // the legacy behavior so the bar/toggle don't flicker on cold start, and // re-show automatically once the agent appears on PATH. -const CLI_GATED_ITEMS: ReadonlySet = new Set(['claude', 'codex', 'gemini', 'kimi']) +const CLI_GATED_ITEMS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'gemini', + 'kimi', + 'grok' +]) export function isStatusBarItemAvailable( id: StatusBarItem, diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 23de9434c37..2dbba23155a 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -71,6 +71,7 @@ function usageSettings(overrides: Partial = {}): UsagePro opencodeSessionCookie: '', geminiCliOAuthEnabled: false, minimaxCookieConfigured: false, + grokAuthConfigured: false, ...overrides } } @@ -119,6 +120,7 @@ describe('hasUsageProviderSettings', () => { hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) + expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) }) it('does not treat empty or unloaded settings as configured', () => { @@ -160,6 +162,14 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('minimax', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('minimax', null)).toBe(false) }) + + it('treats grokAuthConfigured as the durable signal for Grok', () => { + expect( + hasUsageProviderSettingsForProvider('grok', usageSettings({ grokAuthConfigured: true })) + ).toBe(true) + expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('grok', null)).toBe(false) + }) }) describe('getVisibleUsageProvider', () => { @@ -235,6 +245,20 @@ describe('getVisibleUsageProvider', () => { }) }) + it('keeps Grok visible while the snapshot is pending when CLI auth is configured', () => { + const visible = getVisibleUsageProvider( + 'grok', + null, + usageSettings({ grokAuthConfigured: true }) + ) + expect(visible).toMatchObject({ + provider: 'grok', + status: 'fetching', + session: null, + weekly: null + }) + }) + it('keeps MiniMax visible when the fetch returns unavailable for a configured cookie', () => { const unavailable = provider('unavailable', { provider: 'minimax', @@ -271,7 +295,8 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, - minimax: null + minimax: null, + grok: null }, usageSettings() ) @@ -287,7 +312,8 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }) }, usageSettings() ) @@ -303,7 +329,8 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }) }, usageSettings({ codexManagedAccounts: [ @@ -330,7 +357,8 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, - minimax: null + minimax: null, + grok: null }, null ) @@ -346,7 +374,8 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), - minimax: provider('unavailable', { provider: 'minimax' }) + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }) }, usageSettings() ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index bbac8ba8a38..54e7ad1a362 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -8,11 +8,9 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { - // Why: the MiniMax cookie lives in the file system, not GlobalSettings, so - // we can't derive durability from settings alone. The renderer threads the - // flag from RateLimitState (pushed by the main process) so the bar stays - // visible across reloads and between snapshot refreshes. + // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean + grokAuthConfigured: boolean } type UsageProviderSnapshots = { @@ -22,6 +20,7 @@ type UsageProviderSnapshots = { opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null } type UsageProviderId = ProviderRateLimits['provider'] @@ -66,7 +65,8 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - settings?.minimaxCookieConfigured === true + settings?.minimaxCookieConfigured === true || + settings?.grokAuthConfigured === true ) } @@ -92,6 +92,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true } + if (providerId === 'grok') { + return settings.grokAuthConfigured === true + } return false } @@ -140,7 +143,8 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || isProviderSnapshotPending(providers.kimi) || - isProviderSnapshotPending(providers.minimax) + isProviderSnapshotPending(providers.minimax) || + isProviderSnapshotPending(providers.grok) ) { return false } @@ -151,6 +155,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && !isProviderConfigured(providers.kimi) && - !isProviderConfigured(providers.minimax) + !isProviderConfigured(providers.minimax) && + !isProviderConfigured(providers.grok) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index a0b54f1a0f5..c013c110463 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -96,6 +96,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'minimax') { return } + if (provider === 'grok') { + return + } return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 3d33fe763f6..f6f9b8d699c 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -20,6 +20,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'minimax') { return 'MiniMax' } + if (provider === 'grok') { + return 'Grok' + } return provider } diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 5ed5b9c93b3..2df2f261506 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -20,6 +20,7 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' export { mode2031SequenceFor } @@ -252,7 +253,7 @@ export function applyTerminalAppearance( // detection behavior; the detection layer simply decides *what* value // `effectiveMacOptionAsAlt` carries. pane.terminal.options.macOptionIsMeta = effectiveMacOptionAsAlt === 'true' - pane.terminal.options.lineHeight = settings.terminalLineHeight + pane.terminal.options.lineHeight = normalizeTerminalLineHeight(settings.terminalLineHeight) // Why call unconditionally: the per-pane helper is a no-op when the // current addon state already matches, so passing the resolved value on // every appearance apply keeps newly-created panes in sync without a diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index a91514856f1..132a52c36d0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -14,6 +14,7 @@ import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' +import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' @@ -1407,7 +1408,7 @@ export function useTerminalPaneLifecycle({ currentSettings?.terminalFastScrollSensitivity ), macOptionIsMeta: effectiveMacOptionAsAltRef.current === 'true', - lineHeight: currentSettings?.terminalLineHeight ?? 1, + lineHeight: normalizeTerminalLineHeight(currentSettings?.terminalLineHeight), wordSeparator: currentSettings?.terminalWordSeparator } }, diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index d5ec61ca3f3..ba50c8dc435 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -69,6 +69,7 @@ import { type WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { CandidateRow } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' import { getCandidateStatus, getContextPillLabel, @@ -212,6 +213,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const openRef = useRef(open) const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) + const [rowsScrollElement, setRowsScrollElement] = useState(null) const [activeView, setActiveView] = useState('ready') const [confirming, setConfirming] = useState(false) const [confirmCandidates, setConfirmCandidates] = useState([]) @@ -505,6 +507,10 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) }, []) + const toggleSelectedRow = useCallback((worktreeId: string) => { + setSelectedIds((current) => toggleSetMember(current, worktreeId)) + }, []) + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { const nextCandidates = filterWorkspaceCleanupRemovalCandidates( candidates, @@ -517,6 +523,28 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setConfirming(true) }, []) + // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow + // instances from re-rendering on scan stream-in and selection changes. + const handleRemoveRow = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + if (loading) { + return + } + setSelectedIds(new Set([candidate.worktreeId])) + openConfirmRemove([candidate]) + }, + [loading, openConfirmRemove] + ) + + const handleViewCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + markCandidateViewed(candidate) + closeModal() + activateAndRevealWorktree(candidate.worktreeId) + }, + [closeModal, markCandidateViewed] + ) + const cancelConfirmRemove = useCallback(() => { if (removalProgress) { closeModal() @@ -777,7 +805,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { onRestoreIgnored={() => void resetDismissals()} /> ) : null} - +
{initialLoading ? : null} {!loading && scan && candidates.length === 0 && !scanNoticeMessage ? ( @@ -851,38 +879,34 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { )} /> ) : null} - {activeRows.map((candidate, index) => ( - 1 && index === activeRows.length - 1} - expanded={expandedRowIds.has(candidate.worktreeId)} - lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} - removing={loading || deletingWorktreeIds.has(candidate.worktreeId)} - selected={ - selectedIds.has(candidate.worktreeId) && - !loading && - !deletingWorktreeIds.has(candidate.worktreeId) - } - failure={rowFailures[candidate.worktreeId]} - onToggleExpanded={toggleExpandedRow} - onToggleSelected={(id) => - setSelectedIds((current) => toggleSetMember(current, id)) - } - onView={closeAndView} - onIgnore={ignoreCandidate} - onRemove={(candidate) => { - if (loading) { - return + ( + - ))} + last={activeRows.length > 1 && index === activeRows.length - 1} + expanded={expandedRowIds.has(candidate.worktreeId)} + lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} + removing={loading || deletingWorktreeIds.has(candidate.worktreeId)} + selected={ + selectedIds.has(candidate.worktreeId) && + !loading && + !deletingWorktreeIds.has(candidate.worktreeId) + } + failure={rowFailures[candidate.worktreeId]} + onToggleExpanded={toggleExpandedRow} + onToggleSelected={toggleSelectedRow} + onView={handleViewCandidate} + onIgnore={ignoreCandidate} + onRemove={handleRemoveRow} + /> + )} + />
@@ -900,12 +924,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ) - - function closeAndView(candidate: WorkspaceCleanupCandidate): void { - markCandidateViewed(candidate) - closeModal() - activateAndRevealWorktree(candidate.worktreeId) - } } function WorkspaceCleanupFilterToolbar({ diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx new file mode 100644 index 00000000000..6779c05f6e0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS, + WorkspaceCleanupCandidateList +} from './workspace-cleanup-candidate-list' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function makeRows(count: number): WorkspaceCleanupCandidate[] { + return Array.from({ length: count }, (_, index) => + makeCandidate({ worktreeId: `wt-${index}`, displayName: `Workspace ${index}` }) + ) +} + +describe('WorkspaceCleanupCandidateList', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('renders every row in natural flow below the virtualization threshold', () => { + const rows = makeRows(WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS - 1) + const rendered: string[] = [] + + act(() => { + root?.render( + { + rendered.push(candidate.worktreeId) + return
+ }} + /> + ) + }) + + expect(rendered).toHaveLength(rows.length) + expect(container?.querySelectorAll('[data-testid="row"]')).toHaveLength(rows.length) + // Plain path keeps natural flow: no absolute-positioned windowing wrappers. + expect(container?.querySelector('[data-index]')).toBeNull() + }) + + it('windows rows into an absolutely positioned container at the threshold', () => { + const rows = makeRows(WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS) + // A real element enables the virtualizer; happy-dom reports zero-size layout, + // so this asserts the windowed structure rather than a specific mounted count. + const scrollElement = document.createElement('div') + + act(() => { + root?.render( +
} + /> + ) + }) + + const windowed = container?.querySelector('.absolute') != null + const mounted = container?.querySelectorAll('[data-testid="row"]').length ?? 0 + // Windowed mode never mounts more than the full set, and switches away from + // the plain flow used below the threshold. + expect(mounted).toBeLessThanOrEqual(rows.length) + expect(windowed || mounted === 0).toBe(true) + }) + + it('memoizes CandidateRow so unchanged rows skip re-render', () => { + expect((CandidateRow as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx new file mode 100644 index 00000000000..68569c7aba9 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +// Why: below this count plain rows keep the pre-virtualization DOM (natural +// flow, no absolute positioning), so the common few-worktrees case is +// byte-for-byte unchanged. The O(N) stream-in churn and per-keystroke re-render +// only bite at the hundreds-to-thousands a heavy multi-agent user accumulates. +export const WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS = 40 +// Why: a collapsed row is a single metadata line (~48px with px-3 py-2.5); +// expanded rows and failure banners are taller, so estimate the common height +// and let measureElement correct the tall variants. +const WORKSPACE_CLEANUP_ROW_ESTIMATE_PX = 48 +const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8 + +/** + * Windows the cleanup candidate rows inside the dialog's ScrollArea viewport. + * The list is the viewport's only content, so rows sit at scroll offset 0 and + * no scroll-margin bookkeeping is needed. Lists shorter than + * WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly. + */ +export function WorkspaceCleanupCandidateList({ + rows, + renderRow, + // Why: a state-held element, not a ref — the ScrollArea viewport is not + // attached when this component first mounts, so a ref would leave the + // virtualizer unobserved until some unrelated re-render. + scrollElement +}: { + rows: readonly WorkspaceCleanupCandidate[] + renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode + scrollElement: HTMLDivElement | null +}): React.JSX.Element { + const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS + + const virtualizer = useVirtualizer({ + count: rows.length, + enabled: virtualize && scrollElement !== null, + getScrollElement: () => scrollElement, + estimateSize: () => WORKSPACE_CLEANUP_ROW_ESTIMATE_PX, + overscan: WORKSPACE_CLEANUP_ROW_OVERSCAN, + // Why: stable worktree keys let the virtualizer carry row identity across + // scan refreshes instead of remounting the window on every streamed row. + getItemKey: (index) => rows[index]?.worktreeId ?? index + }) + + if (!virtualize) { + return <>{rows.map((candidate, index) => renderRow(candidate, index))} + } + + return ( +
+ {virtualizer.getVirtualItems().map((item) => { + const candidate = rows[item.index] + if (candidate === undefined) { + return null + } + return ( +
+ {renderRow(candidate, item.index)} +
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index 04fb02d28c2..faf540f3a29 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -92,7 +92,12 @@ function MetadataIconChip({ ) } -export function CandidateRow({ +// Why: the cleanup list re-renders on every checkbox/expand/search keystroke; +// memo keeps each unchanged row from re-rendering. Effective only while the +// parent passes stable (useCallback) handlers — see WorkspaceCleanupDialog. +// Scan stream-in still re-renders rows (candidates change, so the reviewInfo +// prop identity changes); virtualization, not memo, bounds that cost. +export const CandidateRow = React.memo(function CandidateRow({ candidate, expanded, failure, @@ -321,7 +326,7 @@ export function CandidateRow({
) -} +}) function formatCompactActivityLabel(label: string): string { if (label === 'Just now') { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1cebfdce6d6..f8d46e38b8c 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -6009,6 +6009,138 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('accepts WSL-relayed status events for a local repo (wsl:* is transport provenance, not ownership)', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'WSL Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'wsl p', + agentType: 'claude', + worktreeId: 'wt-1', + connectionId: 'wsl:Ubuntu', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ state: 'working', prompt: 'wsl p', agentType: 'claude' }), + 'WSL Tab', + { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, + expectWorktreeRouting('wt-1'), + undefined + ) + }) + + it('still rejects WSL-relayed status events against an SSH-owned repo', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + repos: [{ id: 'repo-1', connectionId: 'ssh-1' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'wsl p', + agentType: 'claude', + worktreeId: 'wt-1', + connectionId: 'wsl:Ubuntu', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + }) + + expect(setAgentStatus).not.toHaveBeenCalled() + }) + it('still rejects remote status events once the pane resolves to a local repo', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index debbe00a545..80e5c2e30c0 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -29,6 +29,7 @@ import type { } from '../../../shared/remote-workspace-types' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { SshConnectionState } from '../../../shared/ssh-types' +import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { RuntimeBrowserDriverState, RuntimeTerminalPresentation, @@ -2988,15 +2989,23 @@ export function useIpcEvents(): void { // matches that tab's worktree, accept the status until repo ownership // becomes available; once ownership is resolved, keep the strict // connectionId check below. + // Why: the WSL hook relay stamps a transport-provenance connectionId + // (`wsl:`), but the pane is a LOCAL pane on a local repo — + // ownership-wise it is null. Without this normalization the strict + // check below drops every WSL-relayed status for a local repo (while + // still rejecting WSL-stamped events against SSH-owned repos). + const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) + ? null + : data.connectionId const canAcceptPendingRemoteOwnership = - data.connectionId !== undefined && - data.connectionId !== null && + ownershipConnectionId !== undefined && + ownershipConnectionId !== null && !repoConnectionResolved && data.worktreeId !== undefined && data.worktreeId === owningWorktreeId if ( - data.connectionId !== undefined && - data.connectionId !== repoConnectionId && + ownershipConnectionId !== undefined && + ownershipConnectionId !== repoConnectionId && !canAcceptPendingRemoteOwnership ) { return 'dropped' diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 531933be42b..03ba64e44f0 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -154,7 +154,7 @@ export function buildSettingsNavigationMetadata({ ), description: translate( 'auto.hooks.useSettingsNavigationMetadata.b1c2f8b0ac', - 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.' + 'Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.' ), icon: UserCog, searchEntries: getAccountsPaneSearchEntries(), @@ -424,7 +424,7 @@ export function buildSettingsNavigationMetadata({ title: translate('auto.hooks.useSettingsNavigationMetadata.d72a58b5b9', 'Stats & Usage'), description: translate( 'auto.hooks.useSettingsNavigationMetadata.b351014180', - 'Orca stats plus Claude, Codex, and OpenCode usage analytics.' + 'Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.' ), icon: BarChart3, searchEntries: getStatsPaneSearchEntries(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0641f95e3e5..4c2ee4a108a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "Show MiniMax subscription usage for the active workspace.", "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", - "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners." + "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", + "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." } } }, @@ -635,7 +636,7 @@ "94a5afe910": "SSH Hosts", "40d80bad8a": "Beta", "de0c2907a1": "Remote Orca Servers", - "b351014180": "Orca stats plus Claude, Codex, and OpenCode usage analytics.", + "b351014180": "Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.", "d72a58b5b9": "Stats & Usage", "dcd0d9b74f": "Keyboard shortcuts for common actions.", "94295ebfb3": "Shortcuts", @@ -676,7 +677,7 @@ "cd50cec5d7": "Coordinate multiple coding agents through Orca.", "58a868e8e4": "Orchestration", "7c79d3b7bf": "Optional", - "b1c2f8b0ac": "Optional account switching for Claude, Codex, Gemini, and OpenCode Go.", + "b1c2f8b0ac": "Optional account switching and usage setup for Claude, Codex, Gemini, OpenCode Go, MiniMax, and Grok.", "f70ac54d38": "AI Provider Accounts", "4121f7a0a2": "Manage AI agents, set a default, and customize commands.", "b49abbd2f7": "Agents", @@ -3032,7 +3033,9 @@ "c0e972d726": "Cancel", "06741a2f3d": "Open MiniMax usage details", "3bbf140864": "MiniMax Usage", - "remoteServerLabel": "Remote server" + "remoteServerLabel": "Remote server", + "grokUsageAria": "Open Grok usage details", + "grokUsageMenu": "Grok Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "Overview", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "overview" + "eee19cfade": "overview", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "sessions", @@ -3431,8 +3435,13 @@ "0bba8ca244": "statistics", "0e2a0b6431": "usage", "372debfac0": "stats", - "26bb901fcd": "Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.", - "cb2430ae6a": "Stats & Usage" + "26bb901fcd": "Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.", + "cb2430ae6a": "Stats & Usage", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "subscription", + "d6e9f0a1b2": "credits", + "a3b6c7d8e9": "grok usage", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "Input", "a8b7487ff7": "Output", "cfe2282ffa": "Unknown" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok usage", + "b2d3e4f5c6": "Weekly subscription credits from Grok CLI OAuth (~/.grok/auth.json). Same source as the status bar.", + "c3e4f5a6b7": "Set up in Accounts", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Refresh Grok usage", + "d4f5a6b7c8": "Refresh", + "e5a6b7c8d9": "Weekly credits used", + "f6b7c8d9e0": "Billing period reset", + "a7b8c9d0e1": "Grok account settings" } }, "sparse": { @@ -6314,7 +6334,7 @@ "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "Connect this browser to a saved Orca server.", "bd0181eeca": "Remote Orca Servers", - "8acf3f22e0": "Orca stats plus Claude, Codex, and OpenCode usage analytics.", + "8acf3f22e0": "Orca stats plus Claude, Codex, OpenCode token analytics and Grok subscription usage.", "954a8f5aef": "Stats & Usage", "a737a4bb22": "Keyboard shortcuts for common actions.", "23bf7a1ad4": "Shortcuts", @@ -6984,7 +7004,13 @@ "b84a5b0c8a": "Choose whether provider accounts are inspected and added on this device or in WSL.", "d09fb5ca92": "Account Location", "733f9e2a93": "MiniMax Usage", - "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching.", + "f4a8c2e1b7": "Grok (xAI) Usage", + "e3b7d1f9a2": "OAuth sign-in via Grok CLI (grok login) for weekly credit usage.", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "oauth", + "a9f3d7b5c8": "login" } }, "advanced": { @@ -7198,7 +7224,11 @@ "cardLayout": "card layout", "workspaceOptions": "workspace options", "detailed": "detailed" - } + }, + "f8e2a1c4b6": "Grok Usage", + "e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8938,6 +8968,21 @@ } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Shows weekly credit usage from your Grok CLI sign-in (session file ~/.grok/auth.json).", + "0d8e77bc40": "Grok CLI docs", + "ad47a33f72": "Loading…", + "b2c3d4e5f6": "Signed in", + "c3d4e5f6a7": "Signed in. Orca only reads that file on disk — run grok login again if usage fails.", + "d4e5f6a7b8": "Session expired — run grok login in a terminal to refresh.", + "e5f6a7b8c9": "Not signed in to Grok CLI", + "f6a7b8c9d0": "In a terminal, run grok login, then click Refresh usage here.", + "3325d996cb": "Refresh usage", + "a8f3e2c1b4": "Weekly credits", + "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", + "c6d1a8f4e2": "Resets {{when}}" } }, "right": { @@ -9968,7 +10013,9 @@ "systemRole": "System", "sessionRole": "Session", "jumpToOriginalPane": "Jump to Original Pane", - "jumpToWorktree": "Jump to Worktree" + "jumpToWorktree": "Jump to Worktree", + "subagentCountSingular": "1 subagent", + "subagentCountPlural": "{{value0}} subagents" }, "FileExplorerNameFilter": { "26fb73c6e3": "Find files", @@ -10023,6 +10070,11 @@ "archivedJumpUnavailable": "This session is in an archived worktree.", "noActiveWorktreeMatch": "No active worktree matches this session.", "noActiveWorktreeTarget": "No active worktree is available." + }, + "AiVaultSessionSubagents": { + "subagentsCount": "Subagents ({{value0}})", + "messageCount": "{{value0}} msgs", + "viewLog": "View Log" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 61d64fd32ff..bf30e7508be 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.", - "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos." + "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", + "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." } } }, @@ -562,10 +563,10 @@ "wslDefault": "WSL predeterminado" }, "sidebarWorktreeActivation": { - "wakeEphemeralVmFailed": "No se pudo reactivar el espacio de trabajo de la VM efímera" + "wakeEphemeralVmFailed": "Error al activar el espacio de trabajo VM efímera" }, "ephemeralVmWorkspaceTarget": { - "projectRootRegistrationFailed": "No se pudo registrar en el host la raíz del proyecto creada por la receta." + "projectRootRegistrationFailed": "Error al registrar la raíz del proyecto creada por la receta en el entorno de ejecución" }, "blocked": { "notification": { @@ -591,8 +592,8 @@ "chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.", "folderWorkspaceCreateFailedTitle": "No se pudo crear el espacio de trabajo de carpeta", "folderWorkspaceCreateFailedMessage": "No se pudo crear el espacio de trabajo de carpeta. Revisa los detalles del error de arriba e inténtalo de nuevo.", - "setupAgentStartupPolicySaveFailed": "No se pudo guardar el comportamiento de inicio del setup.", - "5f3d2c8a1b": "No se pudo resolver la base del MR." + "setupAgentStartupPolicySaveFailed": "Error al guardar el comportamiento de inicio", + "5f3d2c8a1b": "Error al resolver la base del MR" }, "useGlobalFileDrop": { "38c9f034ff": "No se pudieron cargar los archivos soltados.", @@ -635,7 +636,7 @@ "94a5afe910": "Hosts SSH", "40d80bad8a": "Beta", "de0c2907a1": "Servidores remotos de Orca", - "b351014180": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", + "b351014180": "Estadísticas de Orca más análisis de tokens de Claude, Codex y OpenCode y uso de suscripción de Grok.", "d72a58b5b9": "Estadísticas y uso", "dcd0d9b74f": "Atajos de teclado para acciones comunes.", "94295ebfb3": "Atajos", @@ -676,14 +677,14 @@ "cd50cec5d7": "Coordina múltiples coding agents a través de Orca.", "58a868e8e4": "Orquestación", "7c79d3b7bf": "Opcional", - "b1c2f8b0ac": "Cambio de cuenta opcional para Claude, Codex, Gemini y OpenCode Go.", + "b1c2f8b0ac": "Configuración opcional de cuentas y uso para Claude, Codex, Gemini, OpenCode Go, MiniMax y Grok.", "f70ac54d38": "Cuentas de proveedores de IA", "4121f7a0a2": "Administra agentes de IA, define uno predeterminado y personaliza comandos.", "b49abbd2f7": "Agentes", - "dev": "Dev Tools", - "devDescription": "Herramientas solo para dev para probar estados de UI.", + "dev": "Herramientas de desarrollo", + "devDescription": "Herramientas exclusivas para desarrolladores para probar estados de UI.", "devBadge": "Dev", - "devSearchNotificationPlayground": "Panel de prueba de notificaciones", + "devSearchNotificationPlayground": "Área de pruebas de notificaciones", "devSearchNotificationPlaygroundDescription": "Activa estados representativos de notificaciones y toasts en la interfaz.", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", @@ -901,17 +902,17 @@ "someone": "alguien", "assigned": "asignó a", "unassigned": "quitó a", - "mentioned": "mencionó esto", + "mentioned": "mencionó este issue", "in": "en", - "closed": "cerró esto", - "reopened": "reabrió esto", - "moved": "movió esto", + "closed": "cerró este issue", + "reopened": "reabrió este issue", + "moved": "movió este issue", "from": "de", "to": "a", "activity": "Actividad", - "noActivity": "Aún no hay actividad." + "noActivity": "Sin actividad aún." }, - "checkActionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de desbloquear el merge." + "checkActionRequiredHint": "Esta verificación necesita una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que se desbloquee la fusión." }, "GitLabItemDialog": { "65e784c1f1": "Reabrir", @@ -1381,7 +1382,7 @@ "8ff5ae8866": "Asignados", "82c87eceb9": "Editar asignados", "1ff5d979df": "Nadie asignado", - "checkActionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de desbloquear el merge.", + "checkActionRequiredHint": "Esta verificación necesita una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que se desbloquee la fusión.", "dd5d9a4f17": "actualizado {{value0}}", "71a3c0f9d2": "Iniciar espacio de trabajo" }, @@ -1732,9 +1733,9 @@ "useIssueNumber": "Usar issue #{{value0}}", "noMatchingIssuesLoaded": "No se cargaron issues coincidentes.", "editReviewersWithCurrent": "Editar revisores: {{value0}}", - "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", - "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", - "linearFetchMore": "Fetch more" + "linearEmptyAttributeFilter": "Ningún issue coincide con los filtros seleccionados. Limpia un filtro o prueba con otros criterios.", + "linearEmptyUnfilteredScope": "No hay issues en el alcance de este espacio de trabajo. Intenta buscar o ajustar los equipos.", + "linearFetchMore": "Cargar más" }, "Terminal": { "73768427cf": "Cerrar", @@ -2596,7 +2597,7 @@ "c00421d320": "Se copió el contexto del fork. Inicia un agente y pégalo para iniciar el fork.", "f62b40e2c7": "No hay contexto de terminal para copiar", "373a3103e7": "Contexto copiado", - "3fc568a49d": "No se pudo copiar el contexto." + "3fc568a49d": "Error al copiar el contexto." } } }, @@ -2651,9 +2652,9 @@ "title": "Se requiere conexión SSH", "connectingButton": "Conectando...", "connectButton": "Conectar", - "removedTitle": "SSH host removed", - "removedBody": "The SSH host for this workspace was removed, so it can no longer connect. Remove the workspace to clear it — remote files are left untouched.", - "removeWorkspaceButton": "Remove workspace" + "removedTitle": "Host SSH eliminado", + "removedBody": "El host SSH de este espacio de trabajo fue eliminado, por lo que ya no puede conectarse. Elimina el espacio de trabajo para limpiarlo — los archivos remotos permanecen intactos.", + "removeWorkspaceButton": "Eliminar espacio de trabajo" } } }, @@ -2863,7 +2864,7 @@ "a768f428f1": "Cerrar pestaña" }, "TerminalTabSplitMenuSection": { - "splitTerminal": "Split terminal" + "splitTerminal": "Dividir terminal" } } }, @@ -3032,7 +3033,9 @@ "c0e972d726": "Cancelar", "06741a2f3d": "Abrir detalles de uso de MiniMax", "3bbf140864": "Uso de MiniMax", - "remoteServerLabel": "Servidor remoto" + "remoteServerLabel": "Servidor remoto", + "grokUsageAria": "Abrir detalles de uso de Grok", + "grokUsageMenu": "Uso de Grok" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "Resumen", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "resumen" + "eee19cfade": "resumen", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "sesiones", @@ -3431,8 +3435,13 @@ "0bba8ca244": "estadísticas", "0e2a0b6431": "uso", "372debfac0": "estadísticas", - "26bb901fcd": "Estadísticas de Orca más analíticas de uso combinadas de Claude, Codex y OpenCode: tokens, caché, modelos y sesiones.", - "cb2430ae6a": "Estadísticas y uso" + "26bb901fcd": "Estadísticas de Orca más análisis de tokens de Claude, Codex y OpenCode y uso de suscripción de Grok.", + "cb2430ae6a": "Estadísticas y uso", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "suscripción", + "d6e9f0a1b2": "créditos", + "a3b6c7d8e9": "uso de grok", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "Entrada", "a8b7487ff7": "Salida", "cfe2282ffa": "Desconocido" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Uso de Grok", + "b2d3e4f5c6": "Créditos semanales de suscripción desde OAuth de Grok CLI (~/.grok/auth.json). Es la misma fuente que la barra de estado.", + "c3e4f5a6b7": "Configurar en Cuentas", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Actualizar uso de Grok", + "d4f5a6b7c8": "Actualizar", + "e5a6b7c8d9": "Créditos semanales usados", + "f6b7c8d9e0": "Restablecimiento del periodo de facturación", + "a7b8c9d0e1": "Configuración de la cuenta de Grok" } }, "sparse": { @@ -4198,7 +4218,7 @@ "hostDisconnected": "Desconectado", "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", "sidebarRowMissing": "El destino ya no existe", - "failedUnnestWorkspace": "No se pudo quitar la anidación del espacio de trabajo" + "failedUnnestWorkspace": "Error al expandir el espacio de trabajo" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -4497,14 +4517,14 @@ "7a8b9c0d1e": "Abrir ajustes", "8b9c0d1e2f": "Quitar host", "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", - "oneWorkspace": "1 workspace", + "oneWorkspace": "1 espacio de trabajo", "manyWorkspaces": "{{count}} workspaces", "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", "alsoForgetLocal": "Also remove these {{value0}} from Orca", "advanced": "Advanced", - "alsoDeleteRemoteHint": "Permanently deletes the remote Git worktrees and their branches. Cannot be undone.", - "alsoForgetLocalHint": "Clears them from Orca only. Remote files, worktrees, and branches are left untouched." + "alsoDeleteRemoteHint": "Elimina permanentemente los espacios de trabajo Git remotos y sus ramas. Esta acción no se puede deshacer.", + "alsoForgetLocalHint": "Solo limpia de Orca. Los archivos, espacios de trabajo y ramas remotas permanecen intactos." }, "HostRenameDialog": { "1a2b3c4d5e": "Renombrar host", @@ -4619,14 +4639,14 @@ "sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay." }, "ForgetSshWorkspaceDialog": { - "reconnectFailed": "Reconnection failed", + "reconnectFailed": "Error de reconexión", "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", "title": "Delete “{{name}}”?", - "disconnectedBody": "The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.", + "disconnectedBody": "El host SSH de este espacio de trabajo no está conectado. Reconéctalo para eliminarlo también en el remoto, o elimínalo solo de Orca.", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", "cancel": "Cancel", - "forget": "Remove from Orca", - "reconnectAndDelete": "Reconnect & Delete" + "forget": "Eliminar de Orca", + "reconnectAndDelete": "Reconectar y eliminar" } }, "shared": { @@ -4649,15 +4669,15 @@ "63520148e2": "La sesión {{value0}} se negó a salir.", "cc0a26cb14": "{{value0}} sesiones se negaron a salir.", "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", - "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", - "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", - "1f0d8ac762": "Terminal cleanup finished with errors.", - "80b6ea14cf": "Terminal cleanup finished with warnings.", - "47cd2a50e9": "No sessions or terminal tabs were reported.", - "c34fb1098d": "Terminal tabs closed and shutdown requested.", - "d9657ac204": "Terminal session shutdown requested.", - "e8f25bd903": "Couldn’t finish terminal cleanup.", - "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." + "2e57c1a940": "El resultado del apagado del daemon no está verificado porque su solicitud de gestión falló.", + "993af6052c": "La gestión del daemon reportó salida: {{value0}}/{{value1}}; aún presente antes de la limpieza exacta: {{value2}}.", + "1f0d8ac762": "La limpieza del terminal terminó con errores.", + "80b6ea14cf": "La limpieza del terminal terminó con advertencias.", + "47cd2a50e9": "No se reportaron sesiones ni pestañas de terminal.", + "c34fb1098d": "Las pestañas de terminal se cerraron y se solicitó el apagado.", + "d9657ac204": "Se solicitó el apagado de la sesión de terminal.", + "e8f25bd903": "No se pudo completar la limpieza del terminal.", + "a702d4196e": "Esto cierra cada pestaña de terminal en todos los espacios de trabajo y solicita el apagado de sus sesiones de terminal actuales. Cualquier trabajo de terminal no guardado se pierde. El daemon en sí continúa ejecutándose y se pueden abrir terminales nuevas inmediatamente. Esta acción no se puede deshacer." } }, "setup": { @@ -4892,9 +4912,9 @@ "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura.", - "codexSessionSource": "Codex home to import from", - "codexSessionSourceInfo": "About importing Codex history", - "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." + "codexSessionSource": "Directorio Codex a importar", + "codexSessionSourceInfo": "Sobre la importación del historial de Codex", + "codexSessionSourceTooltip": "Orca ejecuta Codex en un directorio aislado. Señala aquí a tu directorio Codex existente para importar el historial de sesiones. Dejar vacío usa ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "Icono siguiente", @@ -6277,7 +6297,7 @@ "b5ee17826b": "Empareja runtimes remotos de Orca para sesiones persistentes, estado remoto más completo y traspaso web o móvil.", "7686cb5c36": "Conecta este navegador a un servidor Orca guardado.", "bd0181eeca": "Servidores remotos de Orca", - "8acf3f22e0": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", + "8acf3f22e0": "Estadísticas de Orca más análisis de tokens de Claude, Codex y OpenCode y uso de suscripción de Grok.", "954a8f5aef": "Estadísticas y uso", "a737a4bb22": "Atajos de teclado para acciones comunes.", "23bf7a1ad4": "Atajos", @@ -6947,7 +6967,13 @@ "b84a5b0c8a": "Elija si las cuentas de proveedor se inspeccionan y agregan en este dispositivo o en WSL.", "d09fb5ca92": "Ubicación de cuentas", "733f9e2a93": "Uso de MiniMax", - "f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales." + "f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales.", + "f4a8c2e1b7": "Uso de Grok (xAI)", + "e3b7d1f9a2": "Inicio de sesión OAuth mediante Grok CLI (grok login) para el uso de créditos semanales.", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "OAuth", + "a9f3d7b5c8": "inicio de sesión" } }, "advanced": { @@ -7161,7 +7187,11 @@ "detailed": "detallado" }, "9a115966d3": "Minimizar a la bandeja al cerrar", - "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir." + "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.", + "f8e2a1c4b6": "Uso de Grok", + "e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8486,7 +8516,7 @@ "supportedAgents": "Agentes compatibles con esta receta: {{value0}}.", "unsupportedSavedAgent": "{{value0}} no puede ejecutar esta acción de generación de texto. Elige uno de los agentes compatibles a continuación.", "resolveComments": "Inicia un agente a partir de comentarios de PR o MR sin resolver seleccionados.", - "fixPushFailure": "Start an agent when a pre-push hook or git push fails." + "fixPushFailure": "Inicia un agente cuando falla un hook pre-push o un git push." } } } @@ -8847,12 +8877,12 @@ "notificationPlaygroundDescription": "Disparadores solo para desarrollo para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", "devOnly": "Solo para desarrollo", "orcaCloud": "Orca Cloud", - "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", - "orcaCloudStatus": "Status", - "orcaCloudSignOut": "Sign out", - "orcaCloudConnect": "Connect profile", - "orcaCloudRefresh": "Refresh status", - "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." + "orcaCloudDescription": "Vista previa solo para desarrolladores del inicio de sesión de la nube propia. Oculto en producción; en desarrollo también aparece en el selector de cuentas de la barra lateral cuando se configuran ORCA_CLOUD_API_URL y ORCA_CLOUD_CLIENT_ID.", + "orcaCloudStatus": "Estado", + "orcaCloudSignOut": "Cerrar sesión", + "orcaCloudConnect": "Conectar perfil", + "orcaCloudRefresh": "Actualizar estado", + "orcaCloudNotConfigured": "Configura ORCA_CLOUD_API_URL y ORCA_CLOUD_CLIENT_ID para previsualizar el inicio de sesión de Orca Cloud en esta versión de desarrollo." }, "EphemeralVmRecipeRow": { "useInWorkspace": "Usar en el espacio de trabajo" @@ -8909,35 +8939,50 @@ }, "SourceControlActionRepoOverrideNote": { "agent": "Agente", - "agentArgs": "Argumentos de la CLI", - "commandTemplate": "Plantilla del comando", + "agentArgs": "Argumentos CLI", + "commandTemplate": "Plantilla de comando", "more": "+{{count}} más", - "plural": "Guardar a nivel global no cambiará {{count}} repositorios con sus propias recetas.", + "plural": "Las guardadas globales no cambiarán {{count}} repositorios con sus propias recetas.", "recipe": "Receta", - "review": "Revisar", + "review": "Revisión", "reviewFirst": "Revisar primero", - "singular": "Guardar a nivel global no cambiará 1 repositorio con su propia receta.", - "tooltipTitle": "Anulaciones de repositorio" + "singular": "Las guardadas globales no cambiarán 1 repositorio con su propia receta.", + "tooltipTitle": "Sobrescrituras de repositorio" }, "linear": { "agent": { "skill": { "install": { "cta": { - "copiedCommand": "Copied command.", - "copyFailed": "Failed to copy command.", - "skillLabel": "Agent skill:", - "checking": "Checking...", - "installed": "Installed", - "notInstalled": "Not installed", - "recheck": "Re-check", - "installedDescription": "Agent skill installed. To update it, run:", - "description": "Let your agents read and edit Linear tasks.", - "copyCommand": "Copy command" + "copiedCommand": "Comando copiado.", + "copyFailed": "Error al copiar el comando.", + "skillLabel": "Habilidad del agente:", + "checking": "Verificando...", + "installed": "Instalado", + "notInstalled": "No instalado", + "recheck": "Reverificar", + "installedDescription": "Habilidad del agente instalada. Para actualizarla, ejecuta:", + "description": "Permite que tus agentes lean y editen tareas de Linear.", + "copyCommand": "Copiar comando" } } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Muestra el uso semanal de créditos desde tu inicio de sesión en Grok CLI (archivo de sesión ~/.grok/auth.json).", + "0d8e77bc40": "Documentación de Grok CLI", + "ad47a33f72": "Cargando…", + "b2c3d4e5f6": "Sesión iniciada", + "c3d4e5f6a7": "Sesión iniciada. Orca solo lee ese archivo en disco; ejecuta grok login de nuevo si falla el uso.", + "d4e5f6a7b8": "Sesión vencida; ejecuta grok login en una terminal para renovarla.", + "e5f6a7b8c9": "No has iniciado sesión en Grok CLI", + "f6a7b8c9d0": "En una terminal, ejecuta grok login y luego haz clic en Actualizar uso aquí.", + "3325d996cb": "Actualizar uso", + "a8f3e2c1b4": "Créditos semanales", + "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", + "c6d1a8f4e2": "Se restablece {{when}}" } }, "right": { @@ -9407,21 +9452,21 @@ "d2b9g4f315": "{{value0}} commits por detrás de {{value1}}", "e9c2a5d416": "A la par con {{value0}}", "4b4a7de138": "Abrir página de revisión en el navegador", - "createPrIntentCommitBlockedSummary": "Commit bloqueado: {{value0}} Corrige el problema y vuelve a intentar Crear PR.", + "createPrIntentCommitBlockedSummary": "Commit bloqueado: {{value0}} Corrige el issue y reintenta crear PR.", "pushRecovery": { - "4b37ae99b0": "Start the default AI agent to fix this push failure", - "30b8d4f181": "Fix push failure with AI", - "dd43c47089": "Choose an agent for this push failure", - "ec7bfced55": "Choose agent to fix push failure", - "9e5ccd00aa": "Push failure context unavailable", - "054ead86b1": "Fix Push Failure With AI", - "15b7f210d7": "Choose the agent and edit the full command input before launch.", - "011f9713fc": "Push blocked", - "60bd988f0b": "AI Fix", - "03d238218c": "Details", - "a9bf7c171a": "Push Failed", - "834cb3f23d": "Fix with AI", - "783a808870": "Close" + "4b37ae99b0": "Iniciar el agente de AI predeterminado para corregir este fallo de push", + "30b8d4f181": "Corregir fallo de push con AI", + "dd43c47089": "Elegir un agente para este fallo de push", + "ec7bfced55": "Elegir agente para corregir fallo de push", + "9e5ccd00aa": "Contexto del fallo de push no disponible", + "054ead86b1": "Corregir Fallo de Push con AI", + "15b7f210d7": "Elige el agente y edita la entrada del comando completo antes de iniciar.", + "011f9713fc": "Push bloqueado", + "60bd988f0b": "Corrección AI", + "03d238218c": "Detalles", + "a9bf7c171a": "Push fallido", + "834cb3f23d": "Corregir con AI", + "783a808870": "Cerrar" } }, "SourceControlAgentActionDialog": { @@ -9675,30 +9720,30 @@ "push": { "failure": { "launch": { - "216f762bd7": "Unable to resolve the workspace connection.", - "4f4e0418a0": "Could not build the agent prompt.", - "f2b47026e8": "Push failure prompt is empty. Update Source Control AI settings.", - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "a8b97d2318": "Started an AI agent for the push failure." + "216f762bd7": "No se pudo resolver la conexión del espacio de trabajo.", + "4f4e0418a0": "No se pudo construir el prompt del agente.", + "f2b47026e8": "El prompt de fallo de push está vacío. Actualiza la configuración de AI de Control de Código.", + "d481ab22f9": "El agente de AI guardado no está disponible. Usa Inicio personalizado para elegir otro agente.", + "9bbd9077a2": "No hay agentes de AI habilitados. Configura agentes en Configuración.", + "5540ff50cc": "No se pudo construir el comando de inicio del agente.", + "a8b97d2318": "Se inició un agente de AI para el fallo de push." } } }, "recovery": { "launch": { - "4f4e0418a0": "Could not build the agent prompt.", + "4f4e0418a0": "No se pudo construir el prompt del agente.", "push": { - "empty": "Push failure prompt is empty. Update Source Control AI settings." + "empty": "El prompt de fallo de push está vacío. Actualiza la configuración de AI de Control de Código." }, "commit": { - "empty": "Commit failure prompt is empty. Update Source Control AI settings." + "empty": "El prompt de fallo de commit está vacío. Actualiza la configuración de AI de Control de Código." }, - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "216f762bd7": "Unable to resolve the workspace connection.", - "success": "Started an AI agent for the {{value0}} failure." + "d481ab22f9": "El agente de AI guardado no está disponible. Usa Inicio personalizado para elegir otro agente.", + "9bbd9077a2": "No hay agentes de AI habilitados. Configura agentes en Configuración.", + "5540ff50cc": "No se pudo construir el comando de inicio del agente.", + "216f762bd7": "No se pudo resolver la conexión del espacio de trabajo.", + "success": "Se inició un agente de AI para el fallo de {{value0}}." } } }, @@ -9968,7 +10013,9 @@ "sessionRole": "Sesión", "jumpToOriginalPane": "Saltar al panel original", "jumpToWorktree": "Saltar al worktree", - "recoverableBadge": "No guardada" + "recoverableBadge": "No guardada", + "subagentCountSingular": "1 subagent", + "subagentCountPlural": "{{value0}} subagents" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", @@ -10023,6 +10070,11 @@ "archivedJumpUnavailable": "Esta sesión está en un worktree archivado.", "noActiveWorktreeMatch": "Ningún worktree activo coincide con esta sesión.", "noActiveWorktreeTarget": "No hay ningún worktree activo disponible." + }, + "AiVaultSessionSubagents": { + "subagentsCount": "Subagents ({{value0}})", + "messageCount": "{{value0}} msgs", + "viewLog": "View Log" } } }, @@ -10587,7 +10639,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "Mostrar espacio de trabajo flotante", - "4cb418b991": "Show floating workspace, new activity", + "4cb418b991": "Mostrar espacio de trabajo flotante, nueva actividad", "5785dd9148": "Minimizar el espacio de trabajo flotante", "bfe7809a70": "{{value0}} espacio de trabajo flotante ({{value1}})" }, @@ -11751,22 +11803,22 @@ "5a8e1d4f23": " · " }, "ExternalFileChangeBanner": { - "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "5c02de9b31": "Reloaded from disk", - "d1e830fa22": "Undo", - "90b2ce7d43": "Compare" + "7c41e90d12": "Este archivo cambió en el disco mientras tenías ediciones sin guardar. Guardar sobrescribirá el contenido más reciente del disco.", + "3fa2b8d417": "Recargar desde disco", + "a95d02c644": "Mantener mis ediciones", + "5c02de9b31": "Recargado desde disco", + "d1e830fa22": "Deshacer", + "90b2ce7d43": "Comparar" }, "ExternalFileChangeCompareDialog": { - "4b8de20a11": "File changed on disk", - "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", - "8fe30ab254": "Reading file from disk...", - "e2b1cd0393": "Could not read the file from disk: {{value0}}", - "b6cf20d514": "The file on disk is binary — no text comparison available.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "2c8f1e07b9": "Loading comparison..." + "4b8de20a11": "Archivo cambiado en disco", + "90cc31e4d7": "Versión del disco a la izquierda, tus ediciones sin guardar a la derecha.", + "8fe30ab254": "Leyendo archivo desde disco...", + "e2b1cd0393": "No se pudo leer el archivo desde el disco: {{value0}}", + "b6cf20d514": "El archivo en disco es binario — comparación de texto no disponible.", + "3fa2b8d417": "Recargar desde disco", + "a95d02c644": "Mantener mis ediciones", + "2c8f1e07b9": "Cargando comparación..." } }, "diff": { @@ -12455,7 +12507,7 @@ "fdd26d81cc": "Ajustes de cuenta de Atlassian", "8090504a3e": "Crea un token en", "7b3967c12f": "Token API de Atlassian", - "3d81bf3ab3": "API token", + "3d81bf3ab3": "Token de API", "e91b9a4073": "you@example.com", "2849ddb295": "Email de Atlassian", "70fcd360c4": "https://example.atlassian.net", @@ -12771,27 +12823,27 @@ } }, "linear-issue-attribute-filter-dropdowns": { - "removeFilter": "Remove {{value0}} filter", - "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", - "filters": "Filters", - "allWorkspacesTitle": "Select one workspace", - "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", - "optionsFromTeam": "Options from {{team}}", - "clearAll": "Clear all filters" + "removeFilter": "Eliminar filtro de {{value0}}", + "teamRequired": "Selecciona un equipo para cargar el estado, asignatarios y etiquetas de este espacio de trabajo.", + "filters": "Filtros", + "allWorkspacesTitle": "Selecciona un espacio de trabajo", + "allWorkspacesBody": "Los filtros de estado, asignatario y etiquetas usan IDs de un solo espacio de trabajo de Linear. Elige un espacio de trabajo para filtrar por esos atributos.", + "optionsFromTeam": "Opciones de {{team}}", + "clearAll": "Limpiar todos los filtros" }, "linear-issue-attribute-filter-sections": { - "status": "Status", - "priority": "Priority", - "assignee": "Assignee", - "unassigned": "Unassigned", - "labels": "Labels", - "countSelected": "{{count}} selected", - "selected": "selected", - "searchPriority": "Filter priority…", - "searchStatus": "Filter status…", - "searchLabels": "Filter labels…", - "searchAssignee": "Filter assignee…", - "back": "Back" + "status": "Estado", + "priority": "Prioridad", + "assignee": "Asignatario", + "unassigned": "Sin asignar", + "labels": "Etiquetas", + "countSelected": "{{count}} seleccionados", + "selected": "seleccionados", + "searchPriority": "Filtrar prioridad…", + "searchStatus": "Filtrar estado…", + "searchLabels": "Filtrar etiquetas…", + "searchAssignee": "Filtrar asignatario…", + "back": "Atrás" } }, "i18n": { @@ -12825,9 +12877,9 @@ "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", - "imagePasteFailed": "Image paste failed.", - "worktreeNotReady": "Worktree not ready — try again in a moment.", - "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" + "imagePasteFailed": "Error al pegar imagen.", + "worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.", + "uploadingAttachments": "Subiendo {{value0}} archivo{{value1}} al remoto…" }, "tool": { "running": "Ejecutando…", @@ -12880,7 +12932,7 @@ "allow": "Permitir", "deny": "Denegar" }, - "launchPromptNotDelivered": "No se entregó; revisa el terminal" + "launchPromptNotDelivered": "No entregado — revisa la terminal" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0d719a8dad5..6752fee3018 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", - "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" + "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", + "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" } } }, @@ -562,10 +563,10 @@ "wslDefault": "WSL 既定" }, "sidebarWorktreeActivation": { - "wakeEphemeralVmFailed": "Failed to wake ephemeral VM workspace" + "wakeEphemeralVmFailed": "一時的なVMワークスペースの起動に失敗しました" }, "ephemeralVmWorkspaceTarget": { - "projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime." + "projectRootRegistrationFailed": "ランタイム上でのレシピ作成プロジェクトルートの登録に失敗しました" }, "blocked": { "notification": { @@ -591,8 +592,8 @@ "chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加。", "folderWorkspaceCreateFailedTitle": "フォルダーワークスペースを作成できませんでした", "folderWorkspaceCreateFailedMessage": "フォルダーワークスペースを作成できませんでした。上のエラー詳細を確認して、もう一度お試しください。", - "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.", - "5f3d2c8a1b": "Failed to resolve MR base." + "setupAgentStartupPolicySaveFailed": "セットアップの起動動作の保存に失敗しました", + "5f3d2c8a1b": "MRベースの解決に失敗しました" }, "useGlobalFileDrop": { "38c9f034ff": "ドロップされたファイルのアップロードに失敗しました。", @@ -635,7 +636,7 @@ "94a5afe910": "SSH ホスト", "40d80bad8a": "ベータ", "de0c2907a1": "リモート Orca サーバー", - "b351014180": "Orca の統計と Claude、Codex、OpenCode の使用状況分析。", + "b351014180": "Orca の統計に加え、Claude、Codex、OpenCode のトークン分析と Grok サブスクリプション使用状況。", "d72a58b5b9": "統計と使用状況", "dcd0d9b74f": "よく使う操作のキーボード ショートカット。", "94295ebfb3": "ショートカット", @@ -676,14 +677,14 @@ "cd50cec5d7": "Orca を通じて複数のコーディング agents を調整します。", "58a868e8e4": "オーケストレーション", "7c79d3b7bf": "任意", - "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go のオプションのアカウント切り替え。", + "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax、Grok の任意のアカウント切り替えと使用量設定。", "f70ac54d38": "AI プロバイダー アカウント", "4121f7a0a2": "AI agents を管理し、デフォルトを設定し、コマンドをカスタマイズします。", "b49abbd2f7": "エージェント", - "dev": "Dev Tools", - "devDescription": "Dev-only tools for exercising UI states.", + "dev": "開発者ツール", + "devDescription": "UI状態を操作するための開発者専用ツール。", "devBadge": "Dev", - "devSearchNotificationPlayground": "Notification playground", + "devSearchNotificationPlayground": "通知プレイグラウンド", "devSearchNotificationPlaygroundDescription": "代表的なトーストと通知の UI 状態をトリガーします。", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", @@ -896,22 +897,22 @@ "5c1c973855": "レビュアーを削除", "commentTooLarge": "コメントが大きすぎるため安全に送信できません。", "timeline": { - "completed": "as completed", - "notPlanned": "as not planned", + "completed": "を完了として", + "notPlanned": "未計画として", "someone": "someone", "assigned": "assigned", "unassigned": "unassigned", - "mentioned": "mentioned this", + "mentioned": "がこのイシューをメンションしました", "in": "in", - "closed": "closed this", - "reopened": "reopened this", - "moved": "moved this", + "closed": "がこのイシューをクローズしました", + "reopened": "がこのイシューを再オープンしました", + "moved": "がこのイシューを移動しました", "from": "from", "to": "to", "activity": "アクティビティ", - "noActivity": "No activity yet." + "noActivity": "まだアクティビティはありません。" }, - "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "checkActionRequiredHint": "マージを解除するには、このチェックにGitHub上で手動アクション(例:ワークフロー実行の承認)が必要です。" }, "GitLabItemDialog": { "65e784c1f1": "再度開く", @@ -1381,7 +1382,7 @@ "8ff5ae8866": "担当者", "82c87eceb9": "担当者を編集", "1ff5d979df": "担当者なし", - "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", + "checkActionRequiredHint": "マージを解除するには、このチェックにGitHub上で手動アクション(例:ワークフロー実行の承認)が必要です。", "dd5d9a4f17": "{{value0}}に更新", "71a3c0f9d2": "ワークスペースを開始" }, @@ -1732,9 +1733,9 @@ "useIssueNumber": "Issue #{{value0}} を使用", "noMatchingIssuesLoaded": "一致する issue は読み込まれていません。", "editReviewersWithCurrent": "レビュアーを編集: {{value0}}", - "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", - "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", - "linearFetchMore": "Fetch more" + "linearEmptyAttributeFilter": "選択したフィルターに一致するイシューがありません。フィルターを解除するか、異なる条件を試してください。", + "linearEmptyUnfilteredScope": "このワークスペーススコープにイシューがありません。検索するか、チームを調整してください。", + "linearFetchMore": "さらに読み込む" }, "Terminal": { "73768427cf": "閉じる", @@ -2543,7 +2544,7 @@ "c2f0b72b8d": "入れる", "925f49f210": "ペインを展開する", "df766809e0": "ペインを折りたたむ", - "cff67afad1": "Copy Context" + "cff67afad1": "コンテキストをコピー" }, "TerminalErrorToast": { "e4aa243f8c": "デーモンを再起動します", @@ -2594,9 +2595,9 @@ "f867385bb5": "このフォークのソース ワークスペースが見つかりませんでした。", "046e8d853c": "フォークする terminal コンテキストがありません", "c00421d320": "フォークコンテキストがコピーされました。agent を起動し、貼り付けてフォークを開始します。", - "f62b40e2c7": "No terminal context to copy", - "373a3103e7": "Context copied", - "3fc568a49d": "Failed to copy context." + "f62b40e2c7": "コピーするターミナルコンテキストがありません", + "373a3103e7": "コンテキストをコピーしました", + "3fc568a49d": "コンテキストのコピーに失敗しました" } } }, @@ -2651,9 +2652,9 @@ "title": "SSH 接続が必要です", "connectingButton": "接続中...", "connectButton": "接続", - "removedTitle": "SSH host removed", - "removedBody": "The SSH host for this workspace was removed, so it can no longer connect. Remove the workspace to clear it — remote files are left untouched.", - "removeWorkspaceButton": "Remove workspace" + "removedTitle": "SSHホストが削除されました", + "removedBody": "このワークスペースのSSHホストが削除されたため、接続できません。ワークスペースを削除してクリアしてください — リモートファイルはそのまま残ります。", + "removeWorkspaceButton": "ワークスペースを削除" } } }, @@ -2679,7 +2680,7 @@ "localWorkspacesOnly": "履歴からの再開はローカルワークスペースでのみ利用できます。", "openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。", "sessionQueued": "セッションをキューに追加しました", - "openSupportedWorkspace": "Open a workspace before resuming a session.", + "openSupportedWorkspace": "セッションを再開する前にワークスペースを開いてください。", "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。", "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。" }, @@ -2863,7 +2864,7 @@ "a768f428f1": "タブを閉じる" }, "TerminalTabSplitMenuSection": { - "splitTerminal": "Split terminal" + "splitTerminal": "ターミナルを分割" } } }, @@ -3032,7 +3033,9 @@ "c0e972d726": "キャンセル", "06741a2f3d": "MiniMax の使用量詳細を開く", "3bbf140864": "MiniMax 使用量", - "remoteServerLabel": "リモートサーバー" + "remoteServerLabel": "リモートサーバー", + "grokUsageAria": "Grok の使用状況詳細を開く", + "grokUsageMenu": "Grok の使用状況" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "概要", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "概要" + "eee19cfade": "概要", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "セッション", @@ -3405,7 +3409,7 @@ "share": { "card": { "utils": { - "19f4b4dc75": "github.com/stableai/orca", + "19f4b4dc75": "github.com/stablyai/orca", "d864fc5f98": "出力", "5d66fdd7c2": "入力", "7080aeaebb": "推論", @@ -3431,8 +3435,13 @@ "0bba8ca244": "統計", "0e2a0b6431": "使用法", "372debfac0": "統計", - "26bb901fcd": "Orca の統計に加え、Claude、Codex、OpenCode の使用状況分析、トークン、キャッシュ、モデル、セッションを組み合わせたもの。", - "cb2430ae6a": "統計と使用状況" + "26bb901fcd": "Orca の統計に加え、Claude、Codex、OpenCode のトークン分析と Grok サブスクリプション使用状況。", + "cb2430ae6a": "統計と使用状況", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "サブスクリプション", + "d6e9f0a1b2": "クレジット", + "a3b6c7d8e9": "grok の使用状況", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "入力", "a8b7487ff7": "出力", "cfe2282ffa": "不明" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok の使用状況", + "b2d3e4f5c6": "Grok CLI OAuth (~/.grok/auth.json) から取得した週次サブスクリプションクレジット。ステータスバーと同じ情報源です。", + "c3e4f5a6b7": "アカウントで設定", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Grok 使用状況を更新", + "d4f5a6b7c8": "更新", + "e5a6b7c8d9": "使用済み週次クレジット", + "f6b7c8d9e0": "請求期間のリセット", + "a7b8c9d0e1": "Grok アカウント設定" } }, "sparse": { @@ -4179,7 +4199,7 @@ "hostDisconnected": "切断済み", "failedNestWorkspace": "ワークスペースをネストできませんでした", "sidebarRowMissing": "対象はもう存在しません", - "failedUnnestWorkspace": "Failed to unnest workspace" + "failedUnnestWorkspace": "ワークスペースの展開に失敗しました" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -4497,14 +4517,14 @@ "7a8b9c0d1e": "Open settings", "8b9c0d1e2f": "ホストを削除", "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", - "oneWorkspace": "1 workspace", + "oneWorkspace": "1つのワークスペース", "manyWorkspaces": "{{count}} workspaces", "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", "alsoForgetLocal": "Also remove these {{value0}} from Orca", "advanced": "Advanced", - "alsoDeleteRemoteHint": "Permanently deletes the remote Git worktrees and their branches. Cannot be undone.", - "alsoForgetLocalHint": "Clears them from Orca only. Remote files, worktrees, and branches are left untouched." + "alsoDeleteRemoteHint": "リモートGitワークスペースとそのブランチを完全に削除します。この操作は取り消せません。", + "alsoForgetLocalHint": "Orcaからのみクリアします。リモートファイル、ワークスペース、ブランチはそのまま残ります。" }, "HostRenameDialog": { "1a2b3c4d5e": "Rename host", @@ -4619,14 +4639,14 @@ "sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。" }, "ForgetSshWorkspaceDialog": { - "reconnectFailed": "Reconnection failed", + "reconnectFailed": "再接続に失敗しました", "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", "title": "Delete “{{name}}”?", - "disconnectedBody": "The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.", + "disconnectedBody": "このワークスペースのSSHホストが接続されていません。再接続してリモートでも削除するか、Orcaからのみ削除してください。", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", "cancel": "Cancel", - "forget": "Remove from Orca", - "reconnectAndDelete": "Reconnect & Delete" + "forget": "Orcaから削除", + "reconnectAndDelete": "再接続して削除" } }, "shared": { @@ -4649,15 +4669,15 @@ "63520148e2": "{{value0}} 個のセッションが終了を拒否しました。", "cc0a26cb14": "{{value0}} 個のセッションが終了を拒否しました。", "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", - "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", - "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", - "1f0d8ac762": "Terminal cleanup finished with errors.", - "80b6ea14cf": "Terminal cleanup finished with warnings.", - "47cd2a50e9": "No sessions or terminal tabs were reported.", - "c34fb1098d": "Terminal tabs closed and shutdown requested.", - "d9657ac204": "Terminal session shutdown requested.", - "e8f25bd903": "Couldn’t finish terminal cleanup.", - "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." + "2e57c1a940": "管理リクエストが失敗したため、デーモンのシャットダウン結果が検証されていません。", + "993af6052c": "デーモン管理が終了を報告:{{value0}}/{{value1}};正確なクリーンアップ前にまだ存在:{{value2}}。", + "1f0d8ac762": "ターミナルクリーンアップがエラーで完了しました。", + "80b6ea14cf": "ターミナルクリーンアップが警告付きで完了しました。", + "47cd2a50e9": "セッションまたはターミナルタブが報告されていません。", + "c34fb1098d": "ターミナルタブが閉じられ、シャットダウンリクエストが送信されました。", + "d9657ac204": "ターミナルセッションのシャットダウンリクエストが送信されました。", + "e8f25bd903": "ターミナルクリーンアップを完了できませんでした。", + "a702d4196e": "これにより、すべてのワークスペースのターミナルタブが閉じられ、現在のターミナルセッションのシャットダウンがリクエストされます。保存されていないターミナル作業は失われます。デーモン自体は実行を続け、新しいターミナルはすぐに開けます。この操作は取り消せません。" } }, "setup": { @@ -4877,9 +4897,9 @@ "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手動", "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。", - "codexSessionSource": "Codex home to import from", - "codexSessionSourceInfo": "About importing Codex history", - "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." + "codexSessionSource": "インポートするCodexホーム", + "codexSessionSourceInfo": "Codex履歴のインポートについて", + "codexSessionSourceTooltip": "Orcaは分離されたホームでCodexを実行します。既存のCodexホームを指定してセッション履歴をインポートします。空の場合、~/.codexを使用します。" }, "AppIconSelector": { "d5a112dc9b": "次へのアイコン", @@ -6299,7 +6319,7 @@ "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", "7686cb5c36": "このブラウザを保存された Orca サーバーに接続します。", "bd0181eeca": "リモート Orca サーバー", - "8acf3f22e0": "Orca の統計と Claude、Codex、OpenCode の使用状況分析。", + "8acf3f22e0": "Orca の統計に加え、Claude、Codex、OpenCode のトークン分析と Grok サブスクリプション使用状況。", "954a8f5aef": "統計と使用状況", "a737a4bb22": "よく使う操作のキーボード ショートカット。", "23bf7a1ad4": "ショートカット", @@ -6969,7 +6989,13 @@ "b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。", "d09fb5ca92": "アカウントの場所", "733f9e2a93": "MiniMax 使用量", - "f8374c3151": "ローカルのレート制限取得用に platform.minimax.io のセッション Cookie を貼り付けます。" + "f8374c3151": "ローカルのレート制限取得用に platform.minimax.io のセッション Cookie を貼り付けます。", + "f4a8c2e1b7": "Grok (xAI) の使用状況", + "e3b7d1f9a2": "週次クレジット使用量のための Grok CLI (grok login) による OAuth サインイン。", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "認証", + "a9f3d7b5c8": "ログイン" } }, "advanced": { @@ -7183,7 +7209,11 @@ "detailed": "詳細" }, "9a115966d3": "閉じるときにトレイへ最小化", - "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。" + "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。", + "f8e2a1c4b6": "Grok の使用状況", + "e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8508,7 +8538,7 @@ "supportedAgents": "このレシピでサポートされている agent: {{value0}}。", "unsupportedSavedAgent": "{{value0}} はこのテキスト生成レシピを実行できません。以下からサポートされている agent のいずれかを選択してください。", "resolveComments": "選択した未解決の PR または MR コメントから agent を開始します。", - "fixPushFailure": "Start an agent when a pre-push hook or git push fails." + "fixPushFailure": "pre-pushフックまたはgit pushが失敗したときにエージェントを起動します。" } } } @@ -8847,12 +8877,12 @@ "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", "devOnly": "Dev only", "orcaCloud": "Orca Cloud", - "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", - "orcaCloudStatus": "Status", - "orcaCloudSignOut": "Sign out", - "orcaCloudConnect": "Connect profile", - "orcaCloudRefresh": "Refresh status", - "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." + "orcaCloudDescription": "第一方クラウドサインインの開発者限定プレビュー。本番環境では非表示。開発環境では、ORCA_CLOUD_API_URLとORCA_CLOUD_CLIENT_IDを設定するとサイドバーのアカウント切替にも表示されます。", + "orcaCloudStatus": "ステータス", + "orcaCloudSignOut": "サインアウト", + "orcaCloudConnect": "プロファイルを接続", + "orcaCloudRefresh": "ステータスを更新", + "orcaCloudNotConfigured": "ORCA_CLOUD_API_URLとORCA_CLOUD_CLIENT_IDを設定して、この開発ビルドでOrca Cloudのサインインをプレビューします。" }, "EphemeralVmRecipeRow": { "useInWorkspace": "ワークスペースでの使用" @@ -8908,36 +8938,51 @@ "toggleLabel": "ワークスペースごとの環境を切り替える" }, "SourceControlActionRepoOverrideNote": { - "agent": "Agent", - "agentArgs": "CLI arguments", - "commandTemplate": "Command template", - "more": "+{{count}} more", - "plural": "Global saves won't change {{count}} repositories with their own recipes.", - "recipe": "Recipe", - "review": "Review", - "reviewFirst": "Review first", - "singular": "Global saves won't change 1 repository with its own recipe.", - "tooltipTitle": "Repository overrides" + "agent": "エージェント", + "agentArgs": "CLI引数", + "commandTemplate": "コマンドテンプレート", + "more": "+{{count}} 件", + "plural": "グローバルの保存は、独自のレシピを持つ{{count}}個のリポジトリを変更しません。", + "recipe": "レシピ", + "review": "レビュー", + "reviewFirst": "最初にレビュー", + "singular": "グローバルの保存は、独自のレシピを持つ1つのリポジトリを変更しません。", + "tooltipTitle": "リポジトリのオーバーライド" }, "linear": { "agent": { "skill": { "install": { "cta": { - "copiedCommand": "Copied command.", - "copyFailed": "Failed to copy command.", - "skillLabel": "Agent skill:", - "checking": "Checking...", - "installed": "Installed", - "notInstalled": "Not installed", - "recheck": "Re-check", - "installedDescription": "Agent skill installed. To update it, run:", - "description": "Let your agents read and edit Linear tasks.", - "copyCommand": "Copy command" + "copiedCommand": "コマンドをコピーしました。", + "copyFailed": "コマンドのコピーに失敗しました。", + "skillLabel": "エージェントスキル:", + "checking": "確認中...", + "installed": "インストール済み", + "notInstalled": "未インストール", + "recheck": "再確認", + "installedDescription": "エージェントスキルがインストールされました。更新するには以下を実行してください:", + "description": "エージェントにLinearタスクの読み取りと編集を許可します。", + "copyCommand": "コマンドをコピー" } } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Grok CLI サインインから週次クレジット使用量を表示します (セッションファイル ~/.grok/auth.json)。", + "0d8e77bc40": "Grok CLI ドキュメント", + "ad47a33f72": "読み込み中…", + "b2c3d4e5f6": "サインイン済み", + "c3d4e5f6a7": "サインイン済みです。Orca はディスク上のそのファイルだけを読み取ります。使用状況の取得に失敗する場合は grok login を再実行してください。", + "d4e5f6a7b8": "セッションの有効期限が切れました。ターミナルで grok login を実行して更新してください。", + "e5f6a7b8c9": "Grok CLI にサインインしていません", + "f6a7b8c9d0": "ターミナルで grok login を実行してから、ここで「使用状況を更新」をクリックしてください。", + "3325d996cb": "使用状況を更新", + "a8f3e2c1b4": "週次クレジット", + "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", + "c6d1a8f4e2": "{{when}} にリセット" } }, "right": { @@ -9406,22 +9451,22 @@ "c1a8f3e204": "{{value0}}より1commit 遅れています", "d2b9g4f315": "{{value1}}より{{value0}}commits 遅れています", "e9c2a5d416": "{{value0}}と同じ状態", - "4b4a7de138": "Open review page in browser", - "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR.", + "4b4a7de138": "ブラウザでレビューページを開く", + "createPrIntentCommitBlockedSummary": "コミットがブロックされました:{{value0}} イシューを修正して、PR作成を再試行してください。", "pushRecovery": { - "4b37ae99b0": "Start the default AI agent to fix this push failure", - "30b8d4f181": "Fix push failure with AI", - "dd43c47089": "Choose an agent for this push failure", - "ec7bfced55": "Choose agent to fix push failure", - "9e5ccd00aa": "Push failure context unavailable", - "054ead86b1": "Fix Push Failure With AI", - "15b7f210d7": "Choose the agent and edit the full command input before launch.", - "011f9713fc": "Push blocked", - "60bd988f0b": "AI Fix", - "03d238218c": "Details", - "a9bf7c171a": "Push Failed", - "834cb3f23d": "Fix with AI", - "783a808870": "Close" + "4b37ae99b0": "デフォルトのAIエージェントを起動してこのプッシュ失敗を修正", + "30b8d4f181": "AIでプッシュ失敗を修正", + "dd43c47089": "このプッシュ失敗用のエージェントを選択", + "ec7bfced55": "プッシュ失敗の修正用エージェントを選択", + "9e5ccd00aa": "プッシュ失敗のコンテキストが利用できません", + "054ead86b1": "AIでプッシュ失敗を修正", + "15b7f210d7": "エージェントを選択し、起動前にコマンド入力を編集してください。", + "011f9713fc": "プッシュがブロックされました", + "60bd988f0b": "AI修正", + "03d238218c": "詳細", + "a9bf7c171a": "プッシュ失敗", + "834cb3f23d": "AIで修正", + "783a808870": "閉じる" } }, "SourceControlAgentActionDialog": { @@ -9675,30 +9720,30 @@ "push": { "failure": { "launch": { - "216f762bd7": "Unable to resolve the workspace connection.", - "4f4e0418a0": "Could not build the agent prompt.", - "f2b47026e8": "Push failure prompt is empty. Update Source Control AI settings.", - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "a8b97d2318": "Started an AI agent for the push failure." + "216f762bd7": "ワークスペースの接続を解決できません。", + "4f4e0418a0": "エージェントプロンプトを構築できませんでした。", + "f2b47026e8": "プッシュ失敗プロンプトが空です。ソースコントロールAI設定を更新してください。", + "d481ab22f9": "保存されたAIエージェントが利用できません。「カスタマイズ起動」で別のエージェントを選択してください。", + "9bbd9077a2": "有効なAIエージェントがありません。設定でエージェントを構成してください。", + "5540ff50cc": "エージェント起動コマンドを構築できませんでした。", + "a8b97d2318": "プッシュ失敗用のAIエージェントを起動しました。" } } }, "recovery": { "launch": { - "4f4e0418a0": "Could not build the agent prompt.", + "4f4e0418a0": "エージェントプロンプトを構築できませんでした。", "push": { - "empty": "Push failure prompt is empty. Update Source Control AI settings." + "empty": "プッシュ失敗プロンプトが空です。ソースコントロールAI設定を更新してください。" }, "commit": { - "empty": "Commit failure prompt is empty. Update Source Control AI settings." + "empty": "コミット失敗プロンプトが空です。ソースコントロールAI設定を更新してください。" }, - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "216f762bd7": "Unable to resolve the workspace connection.", - "success": "Started an AI agent for the {{value0}} failure." + "d481ab22f9": "保存されたAIエージェントが利用できません。「カスタマイズ起動」で別のエージェントを選択してください。", + "9bbd9077a2": "有効なAIエージェントがありません。設定でエージェントを構成してください。", + "5540ff50cc": "エージェント起動コマンドを構築できませんでした。", + "216f762bd7": "ワークスペースの接続を解決できません。", + "success": "{{value0}}の失敗に対してAIエージェントを起動しました。" } } }, @@ -9883,7 +9928,7 @@ "currentProjectLower": "現在のプロジェクト", "project": "プロジェクト", "hostScopeAriaLabel": "Session History host: {{value0}}", - "host": "Host" + "host": "ホスト" }, "AiVaultSessionDetails": { "updated": "更新日時", @@ -9968,7 +10013,9 @@ "sessionRole": "セッション", "jumpToOriginalPane": "元のペインにジャンプ", "jumpToWorktree": "ワークツリーにジャンプ", - "recoverableBadge": "未保存" + "recoverableBadge": "未保存", + "subagentCountSingular": "1 subagent", + "subagentCountPlural": "{{value0}} subagents" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", @@ -10023,6 +10070,11 @@ "archivedJumpUnavailable": "このセッションはアーカイブされたワークツリーにあります。", "noActiveWorktreeMatch": "このセッションに一致するアクティブなワークツリーはありません。", "noActiveWorktreeTarget": "利用可能なアクティブなワークツリーはありません。" + }, + "AiVaultSessionSubagents": { + "subagentsCount": "Subagents ({{value0}})", + "messageCount": "{{value0}} msgs", + "viewLog": "View Log" } } }, @@ -10587,7 +10639,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "フローティングワークスペースを表示", - "4cb418b991": "Show floating workspace, new activity", + "4cb418b991": "フローティングワークスペースを表示、新しいアクティビティ", "5785dd9148": "フローティングワークスペースを最小限に抑える", "bfe7809a70": "{{value0}} フローティング ワークスペース ({{value1}})" }, @@ -11751,22 +11803,22 @@ "5a8e1d4f23": " · " }, "ExternalFileChangeBanner": { - "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "5c02de9b31": "Reloaded from disk", - "d1e830fa22": "Undo", - "90b2ce7d43": "Compare" + "7c41e90d12": "未保存の編集中にこのファイルがディスク上で変更されました。保存すると新しいディスク内容に上書きされます。", + "3fa2b8d417": "ディスクから再読み込み", + "a95d02c644": "編集内容を保持", + "5c02de9b31": "ディスクから再読み込みしました", + "d1e830fa22": "取り消す", + "90b2ce7d43": "比較" }, "ExternalFileChangeCompareDialog": { - "4b8de20a11": "File changed on disk", - "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", - "8fe30ab254": "Reading file from disk...", - "e2b1cd0393": "Could not read the file from disk: {{value0}}", - "b6cf20d514": "The file on disk is binary — no text comparison available.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "2c8f1e07b9": "Loading comparison..." + "4b8de20a11": "ファイルがディスク上で変更されました", + "90cc31e4d7": "左がディスクバージョン、右が未保存の編集です。", + "8fe30ab254": "ディスクからファイルを読み込み中...", + "e2b1cd0393": "ディスクからファイルを読み取れませんでした:{{value0}}", + "b6cf20d514": "ディスク上のファイルはバイナリです — テキスト比較は利用できません。", + "3fa2b8d417": "ディスクから再読み込み", + "a95d02c644": "編集内容を保持", + "2c8f1e07b9": "比較を読み込み中..." } }, "diff": { @@ -12455,7 +12507,7 @@ "fdd26d81cc": "Atlassian account settings", "8090504a3e": "Create a token in", "7b3967c12f": "Atlassian API token", - "3d81bf3ab3": "API token", + "3d81bf3ab3": "APIトークン", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian email", "70fcd360c4": "https://example.atlassian.net", @@ -12771,27 +12823,27 @@ } }, "linear-issue-attribute-filter-dropdowns": { - "removeFilter": "Remove {{value0}} filter", - "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", - "filters": "Filters", - "allWorkspacesTitle": "Select one workspace", - "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", - "optionsFromTeam": "Options from {{team}}", - "clearAll": "Clear all filters" + "removeFilter": "{{value0}}フィルターを解除", + "teamRequired": "このワークスペースのステータス、担当者、ラベルを読み込むにはチームを選択してください。", + "filters": "フィルター", + "allWorkspacesTitle": "ワークスペースを選択", + "allWorkspacesBody": "ステータス、担当者、ラベルのフィルターは単一のLinearワークスペースのIDを使用します。フィルタリングするワークスペースを選択してください。", + "optionsFromTeam": "{{team}}からのオプション", + "clearAll": "すべてのフィルターをクリア" }, "linear-issue-attribute-filter-sections": { - "status": "Status", - "priority": "Priority", - "assignee": "Assignee", - "unassigned": "Unassigned", - "labels": "Labels", - "countSelected": "{{count}} selected", - "selected": "selected", - "searchPriority": "Filter priority…", - "searchStatus": "Filter status…", - "searchLabels": "Filter labels…", - "searchAssignee": "Filter assignee…", - "back": "Back" + "status": "ステータス", + "priority": "優先度", + "assignee": "担当者", + "unassigned": "未割当", + "labels": "ラベル", + "countSelected": "{{count}}件選択中", + "selected": "選択中", + "searchPriority": "優先度をフィルター…", + "searchStatus": "ステータスをフィルター…", + "searchLabels": "ラベルをフィルター…", + "searchAssignee": "担当者をフィルター…", + "back": "戻る" } }, "i18n": { @@ -12825,9 +12877,9 @@ "localAttachmentUnsupported": "ローカル添付ファイルはリモート セッションでは使用できません。", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", - "imagePasteFailed": "Image paste failed.", - "worktreeNotReady": "Worktree not ready — try again in a moment.", - "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" + "imagePasteFailed": "画像の貼り付けに失敗しました。", + "worktreeNotReady": "Worktreeが準備できていません — しばらくしてから再試行してください。", + "uploadingAttachments": "{{value0}}個のファイルをリモートにアップロード中…" }, "tool": { "running": "実行中…", @@ -12880,7 +12932,7 @@ "allow": "許可する", "deny": "拒否" }, - "launchPromptNotDelivered": "Not delivered — check the terminal" + "launchPromptNotDelivered": "配信されませんでした — ターミナルを確認してください" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0560c699e94..b0eb225ef80 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "활성 워크스페이스의 MiniMax 구독 사용량을 표시합니다.", "sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", - "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요." + "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", + "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." } } }, @@ -204,7 +205,7 @@ "store": { "test": { "helpers": { - "b9a8117c33": "Terminal 1" + "b9a8117c33": "터미널 1" } } }, @@ -562,10 +563,10 @@ "wslDefault": "WSL 기본값" }, "sidebarWorktreeActivation": { - "wakeEphemeralVmFailed": "Failed to wake ephemeral VM workspace" + "wakeEphemeralVmFailed": "임시 VM 워크트리 활성화 실패" }, "ephemeralVmWorkspaceTarget": { - "projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime." + "projectRootRegistrationFailed": "런타임에서 레시피로 생성된 프로젝트 루트 등록 실패" }, "blocked": { "notification": { @@ -591,8 +592,8 @@ "chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.", "folderWorkspaceCreateFailedTitle": "폴더 워크스페이스를 만들지 못했습니다", "folderWorkspaceCreateFailedMessage": "폴더 워크스페이스를 만들 수 없습니다. 위의 오류 세부 정보를 확인한 뒤 다시 시도하세요.", - "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.", - "5f3d2c8a1b": "Failed to resolve MR base." + "setupAgentStartupPolicySaveFailed": "시작 동작 저장 실패", + "5f3d2c8a1b": "MR베이스 확인 실패" }, "useGlobalFileDrop": { "38c9f034ff": "드롭한 파일을 업로드하지 못했습니다.", @@ -635,7 +636,7 @@ "94a5afe910": "SSH 호스트", "40d80bad8a": "베타", "de0c2907a1": "원격 Orca 서버", - "b351014180": "Orca 통계와 Claude, Codex 및 OpenCode 사용 분석.", + "b351014180": "Orca 통계와 Claude, Codex, OpenCode 토큰 분석 및 Grok 구독 사용량.", "d72a58b5b9": "통계 및 사용량", "dcd0d9b74f": "일반 작업에 대한 키보드 단축키입니다.", "94295ebfb3": "단축키", @@ -676,14 +677,14 @@ "cd50cec5d7": "Orca를 통해 여러 코딩 agents를 조정합니다.", "58a868e8e4": "오케스트레이션", "7c79d3b7bf": "선택 사항", - "b1c2f8b0ac": "Claude, Codex, Gemini 및 OpenCode Go에 대한 선택적 계정 전환.", + "b1c2f8b0ac": "Claude, Codex, Gemini, OpenCode Go, MiniMax, Grok의 선택적 계정 전환 및 사용량 설정.", "f70ac54d38": "AI 제공업체 계정", "4121f7a0a2": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.", "b49abbd2f7": "에이전트", - "dev": "Dev Tools", - "devDescription": "Dev-only tools for exercising UI states.", + "dev": "개발자 도구", + "devDescription": "UI 상태를 테스트하기 위한 개발자 전용 도구.", "devBadge": "Dev", - "devSearchNotificationPlayground": "Notification playground", + "devSearchNotificationPlayground": "알림 플레이그라운드", "devSearchNotificationPlaygroundDescription": "대표적인 토스트 및 알림 UI 상태를 트리거합니다.", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", @@ -896,22 +897,22 @@ "5c1c973855": "리뷰어 삭제", "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.", "timeline": { - "completed": "as completed", - "notPlanned": "as not planned", + "completed": "를 완료로", + "notPlanned": "미계획으로", "someone": "someone", "assigned": "assigned", "unassigned": "unassigned", - "mentioned": "mentioned this", + "mentioned": "이 이슈를 언급했습니다", "in": "in", - "closed": "closed this", - "reopened": "reopened this", - "moved": "moved this", + "closed": "이 이슈를 닫았습니다", + "reopened": "이 이슈를 다시 열었습니다", + "moved": "이 이슈를 이동했습니다", "from": "from", "to": "to", "activity": "활동", - "noActivity": "No activity yet." + "noActivity": "아직 활동이 없습니다." }, - "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "checkActionRequiredHint": "머지가 해제되려면 이 검사에 대해 GitHub에서 수동 작업(예: 워크플로우 실행 승인)이 필요합니다." }, "GitLabItemDialog": { "65e784c1f1": "다시 열기", @@ -1381,7 +1382,7 @@ "8ff5ae8866": "담당자", "82c87eceb9": "담당자 편집", "1ff5d979df": "할당된 사람이 없음", - "checkActionRequiredHint": "이 검사는 병합 차단을 해제하기 전에 GitHub에서 수동 작업(예: 워크플로 실행 승인)이 필요합니다.", + "checkActionRequiredHint": "머지가 해제되려면 이 검사에 대해 GitHub에서 수동 작업(예: 워크플로우 실행 승인)이 필요합니다.", "dd5d9a4f17": "{{value0}}에 업데이트됨", "71a3c0f9d2": "워크스페이스 시작" }, @@ -1732,9 +1733,9 @@ "useIssueNumber": "이슈 #{{value0}} 사용", "noMatchingIssuesLoaded": "일치하는 이슈를 불러오지 않았습니다.", "editReviewersWithCurrent": "리뷰어 편집: {{value0}}", - "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", - "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", - "linearFetchMore": "Fetch more" + "linearEmptyAttributeFilter": "선택한 필터와 일치하는 이슈가 없습니다. 필터를 지우거나 다른 조건을 시도하세요.", + "linearEmptyUnfilteredScope": "이 워크트리 범위에 이슈가 없습니다. 검색하거나 팀을 조정해 보세요.", + "linearFetchMore": "더 불러오기" }, "Terminal": { "73768427cf": "닫기", @@ -2543,7 +2544,7 @@ "c2f0b72b8d": "삽입", "925f49f210": "창 확장", "df766809e0": "창 축소", - "cff67afad1": "Copy Context" + "cff67afad1": "컨텍스트 복사" }, "TerminalErrorToast": { "e4aa243f8c": "데몬 재시작", @@ -2594,9 +2595,9 @@ "f867385bb5": "이 포크의 소스 워크스페이스를 찾을 수 없습니다.", "046e8d853c": "포크할 terminal 컨텍스트가 없습니다.", "c00421d320": "포크 컨텍스트가 복사되었습니다. agent를 실행하고 붙여넣어 포크를 시작합니다.", - "f62b40e2c7": "복사할 terminal 컨텍스트가 없습니다", + "f62b40e2c7": "복사할 터미널 컨텍스트가 없습니다", "373a3103e7": "컨텍스트가 복사되었습니다", - "3fc568a49d": "컨텍스트를 복사하지 못했습니다." + "3fc568a49d": "컨텍스트 복사 실패" } } }, @@ -2651,9 +2652,9 @@ "title": "SSH 연결 필요", "connectingButton": "연결 중...", "connectButton": "연결", - "removedTitle": "SSH host removed", - "removedBody": "The SSH host for this workspace was removed, so it can no longer connect. Remove the workspace to clear it — remote files are left untouched.", - "removeWorkspaceButton": "Remove workspace" + "removedTitle": "SSH 호스트가 제거됨", + "removedBody": "이 워크트리의 SSH 호스트가 제거되어 더 이상 연결할 수 없습니다. 워크트리를 제거하여 정리하세요 — 원격 파일은 그대로 남아 있습니다.", + "removeWorkspaceButton": "워크트리 제거" } } }, @@ -2679,7 +2680,7 @@ "localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.", "openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.", "sessionQueued": "세션이 대기열에 추가됨", - "openSupportedWorkspace": "Open a workspace before resuming a session.", + "openSupportedWorkspace": "세션을 재개하기 전에 워크트리를 여세요.", "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.", "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요." }, @@ -2863,7 +2864,7 @@ "a768f428f1": "탭 닫기" }, "TerminalTabSplitMenuSection": { - "splitTerminal": "Split terminal" + "splitTerminal": "터미널 분할" } } }, @@ -3032,7 +3033,9 @@ "c0e972d726": "취소", "06741a2f3d": "MiniMax 사용량 세부 정보 열기", "3bbf140864": "MiniMax 사용량", - "remoteServerLabel": "원격 서버" + "remoteServerLabel": "원격 서버", + "grokUsageAria": "Grok 사용량 세부 정보 열기", + "grokUsageMenu": "Grok 사용량" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "개요", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "개요" + "eee19cfade": "개요", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "세션", @@ -3431,8 +3435,13 @@ "0bba8ca244": "통계", "0e2a0b6431": "사용량", "372debfac0": "통계", - "26bb901fcd": "Orca 통계, Claude/Codex/OpenCode 통합 사용량 분석, 토큰, 캐시, 모델, 세션", - "cb2430ae6a": "통계 및 사용량" + "26bb901fcd": "Orca 통계와 Claude, Codex, OpenCode 토큰 분석 및 Grok 구독 사용량.", + "cb2430ae6a": "통계 및 사용량", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "구독", + "d6e9f0a1b2": "크레딧", + "a3b6c7d8e9": "grok 사용량", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "입력", "a8b7487ff7": "출력", "cfe2282ffa": "알 수 없음" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok 사용량", + "b2d3e4f5c6": "Grok CLI OAuth(~/.grok/auth.json)의 주간 구독 크레딧입니다. 상태 표시줄과 같은 출처입니다.", + "c3e4f5a6b7": "계정에서 설정", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "Grok 사용량 새로 고침", + "d4f5a6b7c8": "새로 고침", + "e5a6b7c8d9": "사용한 주간 크레딧", + "f6b7c8d9e0": "결제 기간 재설정", + "a7b8c9d0e1": "Grok 계정 설정" } }, "sparse": { @@ -4179,7 +4199,7 @@ "hostDisconnected": "연결 끊김", "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다", "sidebarRowMissing": "대상이 더 이상 존재하지 않습니다", - "failedUnnestWorkspace": "Failed to unnest workspace" + "failedUnnestWorkspace": "워크트리 펼치기 실패" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -4497,14 +4517,14 @@ "7a8b9c0d1e": "설정 열기", "8b9c0d1e2f": "호스트 제거", "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", - "oneWorkspace": "1 workspace", + "oneWorkspace": "워크트리 1개", "manyWorkspaces": "{{count}} workspaces", "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", "alsoForgetLocal": "Also remove these {{value0}} from Orca", "advanced": "Advanced", - "alsoDeleteRemoteHint": "Permanently deletes the remote Git worktrees and their branches. Cannot be undone.", - "alsoForgetLocalHint": "Clears them from Orca only. Remote files, worktrees, and branches are left untouched." + "alsoDeleteRemoteHint": "원격 Git 워크트리와 해당 브랜치를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "alsoForgetLocalHint": "Orca에서만 제거합니다. 원격 파일, 워크트리, 브랜치는 그대로 남아 있습니다." }, "HostRenameDialog": { "1a2b3c4d5e": "호스트 이름 바꾸기", @@ -4619,14 +4639,14 @@ "sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다." }, "ForgetSshWorkspaceDialog": { - "reconnectFailed": "Reconnection failed", + "reconnectFailed": "재연결 실패", "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", "title": "Delete “{{name}}”?", - "disconnectedBody": "The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.", + "disconnectedBody": "이 워크트리의 SSH 호스트가 연결되어 있지 않습니다. 재연결하여 원격에서도 삭제하거나 Orca에서만 제거하세요.", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", "cancel": "Cancel", - "forget": "Remove from Orca", - "reconnectAndDelete": "Reconnect & Delete" + "forget": "Orca에서 제거", + "reconnectAndDelete": "재연결 후 삭제" } }, "shared": { @@ -4649,15 +4669,15 @@ "63520148e2": "{{value0}}개 세션이 종료를 거부했습니다.", "cc0a26cb14": "{{value0}}개 세션이 종료를 거부했습니다.", "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", - "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", - "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", - "1f0d8ac762": "Terminal cleanup finished with errors.", - "80b6ea14cf": "Terminal cleanup finished with warnings.", - "47cd2a50e9": "No sessions or terminal tabs were reported.", - "c34fb1098d": "Terminal tabs closed and shutdown requested.", - "d9657ac204": "Terminal session shutdown requested.", - "e8f25bd903": "Couldn’t finish terminal cleanup.", - "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." + "2e57c1a940": "관리 요청이 실패하여 데몬 종료 결과가 확인되지 않았습니다.", + "993af6052c": "데몬 관리가 종료를 보고함: {{value0}}/{{value1}}; 정확한 정리 전에 아직 존재: {{value2}}.", + "1f0d8ac762": "터미널 정리가 오류와 함께 완료되었습니다.", + "80b6ea14cf": "터미널 정리가 경고와 함께 완료되었습니다.", + "47cd2a50e9": "세션이나 터미널 탭이 보고되지 않았습니다.", + "c34fb1098d": "터미널 탭이 닫히고 종료 요청이 전송되었습니다.", + "d9657ac204": "터미널 세션 종료 요청이 전송되었습니다.", + "e8f25bd903": "터미널 정리를 완료할 수 없습니다.", + "a702d4196e": "이 작업은 모든 워크트리의 모든 터미널 탭을 닫고 현재 터미널 세션의 종료를 요청합니다. 저장되지 않은 터미널 작업은 손실됩니다. 데몬 자체는 계속 실행되며 새 터미널은 즉시 열 수 있습니다. 이 작업은 되돌릴 수 없습니다." } }, "setup": { @@ -4877,9 +4897,9 @@ "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "수동", "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다.", - "codexSessionSource": "Codex home to import from", - "codexSessionSourceInfo": "About importing Codex history", - "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." + "codexSessionSource": "가져올 Codex 홈", + "codexSessionSourceInfo": "Codex 기록 가져오기 정보", + "codexSessionSourceTooltip": "Orca는 격리된 홈에서 Codex를 실행합니다. 기존 Codex 홈을 지정하여 세션 기록을 가져옵니다. 비워두면 ~/.codex를 사용합니다." }, "AppIconSelector": { "d5a112dc9b": "다음 아이콘", @@ -6262,7 +6282,7 @@ "b5ee17826b": "지속 세션, 더 풍부한 원격 상태, 웹 또는 모바일 핸드오프를 위해 원격 Orca 런타임을 페어링합니다.", "7686cb5c36": "이 브라우저를 저장된 Orca 서버에 연결하세요.", "bd0181eeca": "원격 Orca 서버", - "8acf3f22e0": "Orca 통계와 Claude, Codex 및 OpenCode 사용 분석.", + "8acf3f22e0": "Orca 통계와 Claude, Codex, OpenCode 토큰 분석 및 Grok 구독 사용량.", "954a8f5aef": "통계 및 사용량", "a737a4bb22": "일반 작업에 대한 키보드 단축키입니다.", "23bf7a1ad4": "단축키", @@ -6932,7 +6952,13 @@ "b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.", "d09fb5ca92": "계정 위치", "733f9e2a93": "MiniMax 사용량", - "f8374c3151": "로컬 rate-limit 가져오기에 사용할 platform.minimax.io 세션 Cookie를 붙여넣으세요." + "f8374c3151": "로컬 rate-limit 가져오기에 사용할 platform.minimax.io 세션 Cookie를 붙여넣으세요.", + "f4a8c2e1b7": "Grok(xAI) 사용량", + "e3b7d1f9a2": "주간 크레딧 사용량을 위한 Grok CLI(grok login) OAuth 로그인.", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "OAuth", + "a9f3d7b5c8": "로그인" } }, "advanced": { @@ -7146,7 +7172,11 @@ "cardLayout": "카드 레이아웃", "workspaceOptions": "워크스페이스 옵션", "detailed": "상세" - } + }, + "f8e2a1c4b6": "Grok 사용량", + "e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8471,7 +8501,7 @@ "supportedAgents": "이 레시피에 지원되는 agent: {{value0}}.", "unsupportedSavedAgent": "{{value0}}은(는) 이 텍스트 생성 레시피를 실행할 수 없습니다. 아래에서 지원되는 agent 중 하나를 선택하세요.", "resolveComments": "선택한 해결되지 않은 PR 또는 MR 댓글에서 agent를 시작합니다.", - "fixPushFailure": "Start an agent when a pre-push hook or git push fails." + "fixPushFailure": "pre-push 훅이나 git push가 실패할 때 에이전트를 시작합니다." } } } @@ -8847,12 +8877,12 @@ "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", "devOnly": "Dev only", "orcaCloud": "Orca Cloud", - "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", - "orcaCloudStatus": "Status", - "orcaCloudSignOut": "Sign out", - "orcaCloudConnect": "Connect profile", - "orcaCloudRefresh": "Refresh status", - "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." + "orcaCloudDescription": "1方클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다.", + "orcaCloudStatus": "상태", + "orcaCloudSignOut": "로그아웃", + "orcaCloudConnect": "프로필 연결", + "orcaCloudRefresh": "상태 새로고침", + "orcaCloudNotConfigured": "ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID를 설정하여 이 개발 빌드에서 Orca Cloud 로그인을 미리보세요." }, "EphemeralVmRecipeRow": { "useInWorkspace": "워크스페이스에서 사용" @@ -8908,36 +8938,51 @@ "toggleLabel": "워크스페이스별 환경 전환" }, "SourceControlActionRepoOverrideNote": { - "agent": "Agent", - "agentArgs": "CLI arguments", - "commandTemplate": "Command template", - "more": "+{{count}} more", - "plural": "Global saves won't change {{count}} repositories with their own recipes.", - "recipe": "Recipe", - "review": "Review", - "reviewFirst": "Review first", - "singular": "Global saves won't change 1 repository with its own recipe.", - "tooltipTitle": "Repository overrides" + "agent": "에이전트", + "agentArgs": "CLI 인수", + "commandTemplate": "명령 템플릿", + "more": "+{{count}}개", + "plural": "전역 저장이 자체 레시피를 가진 {{count}}개의 저장소를 변경하지 않습니다.", + "recipe": "레시피", + "review": "검토", + "reviewFirst": "먼저 검토", + "singular": "전역 저장이 자체 레시피를 가진 1개의 저장소를 변경하지 않습니다.", + "tooltipTitle": "저장소 재정의" }, "linear": { "agent": { "skill": { "install": { "cta": { - "copiedCommand": "Copied command.", - "copyFailed": "Failed to copy command.", - "skillLabel": "Agent skill:", - "checking": "Checking...", - "installed": "Installed", - "notInstalled": "Not installed", - "recheck": "Re-check", - "installedDescription": "Agent skill installed. To update it, run:", - "description": "Let your agents read and edit Linear tasks.", - "copyCommand": "Copy command" + "copiedCommand": "명령이 복사되었습니다.", + "copyFailed": "명령 복사 실패.", + "skillLabel": "에이전트 스킬:", + "checking": "확인 중...", + "installed": "설치됨", + "notInstalled": "설치되지 않음", + "recheck": "다시 확인", + "installedDescription": "에이전트 스킬이 설치되었습니다. 업데이트하려면 다음을 실행하세요:", + "description": "에이전트가 Linear 작업을 읽고 편집할 수 있도록 합니다.", + "copyCommand": "명령 복사" } } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "Grok CLI 로그인에서 주간 크레딧 사용량을 표시합니다(세션 파일 ~/.grok/auth.json).", + "0d8e77bc40": "Grok CLI 문서", + "ad47a33f72": "로드 중…", + "b2c3d4e5f6": "로그인됨", + "c3d4e5f6a7": "로그인되었습니다. Orca는 디스크의 해당 파일만 읽습니다. 사용량 조회가 실패하면 grok login을 다시 실행하세요.", + "d4e5f6a7b8": "세션이 만료되었습니다. 터미널에서 grok login을 실행해 갱신하세요.", + "e5f6a7b8c9": "Grok CLI에 로그인되어 있지 않음", + "f6a7b8c9d0": "터미널에서 grok login을 실행한 다음 여기에서 사용량 새로 고침을 클릭하세요.", + "3325d996cb": "사용량 새로 고침", + "a8f3e2c1b4": "주간 크레딧", + "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", + "c6d1a8f4e2": "{{when}}에 재설정" } }, "right": { @@ -9406,22 +9451,22 @@ "c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐", "d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐", "e9c2a5d416": "{{value0}}와 동일한 상태", - "4b4a7de138": "Open review page in browser", - "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR.", + "4b4a7de138": "브라우저에서 검토 페이지 열기", + "createPrIntentCommitBlockedSummary": "커밋이 차단됨: {{value0}} 이슈를 수정한 후 PR 만들기를 다시 시도하세요.", "pushRecovery": { - "4b37ae99b0": "Start the default AI agent to fix this push failure", - "30b8d4f181": "Fix push failure with AI", - "dd43c47089": "Choose an agent for this push failure", - "ec7bfced55": "Choose agent to fix push failure", - "9e5ccd00aa": "Push failure context unavailable", - "054ead86b1": "Fix Push Failure With AI", - "15b7f210d7": "Choose the agent and edit the full command input before launch.", - "011f9713fc": "Push blocked", - "60bd988f0b": "AI Fix", - "03d238218c": "Details", - "a9bf7c171a": "Push Failed", - "834cb3f23d": "Fix with AI", - "783a808870": "Close" + "4b37ae99b0": "기본 AI 에이전트를 시작하여 이 푸시 실패 수정", + "30b8d4f181": "AI로 푸시 실패 수정", + "dd43c47089": "이 푸시 실패용 에이전트 선택", + "ec7bfced55": "푸시 실패 수정용 에이전트 선택", + "9e5ccd00aa": "푸시 실패 컨텍스트를 사용할 수 없음", + "054ead86b1": "AI로 푸시 실패 수정", + "15b7f210d7": "에이전트를 선택하고 시작 전에 전체 명령 입력을 편집하세요.", + "011f9713fc": "푸시 차단됨", + "60bd988f0b": "AI 수정", + "03d238218c": "세부정보", + "a9bf7c171a": "푸시 실패", + "834cb3f23d": "AI로 수정", + "783a808870": "닫기" } }, "SourceControlAgentActionDialog": { @@ -9675,30 +9720,30 @@ "push": { "failure": { "launch": { - "216f762bd7": "Unable to resolve the workspace connection.", - "4f4e0418a0": "Could not build the agent prompt.", - "f2b47026e8": "Push failure prompt is empty. Update Source Control AI settings.", - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "a8b97d2318": "Started an AI agent for the push failure." + "216f762bd7": "워크트리 연결을 확인할 수 없습니다.", + "4f4e0418a0": "에이전트 프롬프트를 구성할 수 없습니다.", + "f2b47026e8": "푸시 실패 프롬프트가 비어 있습니다. 소스 컨트롤 AI 설정을 업데이트하세요.", + "d481ab22f9": "저장된 AI 에이전트를 사용할 수 없습니다. 사용자 지정 시작에서 다른 에이전트를 선택하세요.", + "9bbd9077a2": "활성화된 AI 에이전트가 없습니다. 설정에서 에이전트를 구성하세요.", + "5540ff50cc": "에이전트 시작 명령을 구성할 수 없습니다.", + "a8b97d2318": "푸시 실패에 대한 AI 에이전트가 시작되었습니다." } } }, "recovery": { "launch": { - "4f4e0418a0": "Could not build the agent prompt.", + "4f4e0418a0": "에이전트 프롬프트를 구성할 수 없습니다.", "push": { - "empty": "Push failure prompt is empty. Update Source Control AI settings." + "empty": "푸시 실패 프롬프트가 비어 있습니다. 소스 컨트롤 AI 설정을 업데이트하세요." }, "commit": { - "empty": "Commit failure prompt is empty. Update Source Control AI settings." + "empty": "커밋 실패 프롬프트가 비어 있습니다. 소스 컨트롤 AI 설정을 업데이트하세요." }, - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "216f762bd7": "Unable to resolve the workspace connection.", - "success": "Started an AI agent for the {{value0}} failure." + "d481ab22f9": "저장된 AI 에이전트를 사용할 수 없습니다. 사용자 지정 시작에서 다른 에이전트를 선택하세요.", + "9bbd9077a2": "활성화된 AI 에이전트가 없습니다. 설정에서 에이전트를 구성하세요.", + "5540ff50cc": "에이전트 시작 명령을 구성할 수 없습니다.", + "216f762bd7": "워크트리 연결을 확인할 수 없습니다.", + "success": "{{value0}} 실패에 대한 AI 에이전트가 시작되었습니다." } } }, @@ -9883,7 +9928,7 @@ "currentProjectLower": "현재 프로젝트", "project": "프로젝트", "hostScopeAriaLabel": "Session History host: {{value0}}", - "host": "Host" + "host": "호스트" }, "AiVaultSessionDetails": { "updated": "업데이트됨", @@ -9968,7 +10013,9 @@ "sessionRole": "세션", "jumpToOriginalPane": "원래 창으로 이동", "jumpToWorktree": "작업 트리로 이동", - "recoverableBadge": "저장 안 됨" + "recoverableBadge": "저장 안 됨", + "subagentCountSingular": "1 subagent", + "subagentCountPlural": "{{value0}} subagents" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", @@ -10023,6 +10070,11 @@ "archivedJumpUnavailable": "이 세션은 보관된 작업 트리에 있습니다.", "noActiveWorktreeMatch": "이 세션과 일치하는 활성 작업 트리가 없습니다.", "noActiveWorktreeTarget": "사용 가능한 활성 작업 트리가 없습니다." + }, + "AiVaultSessionSubagents": { + "subagentsCount": "Subagents ({{value0}})", + "messageCount": "{{value0}} msgs", + "viewLog": "View Log" } } }, @@ -10587,7 +10639,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "플로팅 워크스페이스 표시", - "4cb418b991": "Show floating workspace, new activity", + "4cb418b991": "플로팅 워크트리 표시, 새 활동", "5785dd9148": "플로팅 워크스페이스 최소화", "bfe7809a70": "{{value0}} 플로팅 워크스페이스({{value1}})" }, @@ -11751,22 +11803,22 @@ "5a8e1d4f23": " · " }, "ExternalFileChangeBanner": { - "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "5c02de9b31": "Reloaded from disk", - "d1e830fa22": "Undo", - "90b2ce7d43": "Compare" + "7c41e90d12": "저장하지 않은 편집이 있는 동안 이 파일이 디스크에서 변경되었습니다. 저장하면 최신 디스크 내용을 덮어씁니다.", + "3fa2b8d417": "디스크에서 다시 로드", + "a95d02c644": "편집 내용 유지", + "5c02de9b31": "디스크에서 다시 로드됨", + "d1e830fa22": "실행 취소", + "90b2ce7d43": "비교" }, "ExternalFileChangeCompareDialog": { - "4b8de20a11": "File changed on disk", - "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", - "8fe30ab254": "Reading file from disk...", - "e2b1cd0393": "Could not read the file from disk: {{value0}}", - "b6cf20d514": "The file on disk is binary — no text comparison available.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "2c8f1e07b9": "Loading comparison..." + "4b8de20a11": "파일이 디스크에서 변경됨", + "90cc31e4d7": "왼쪽이 디스크 버전, 오른쪽이 저장하지 않은 편집입니다.", + "8fe30ab254": "디스크에서 파일 읽는 중...", + "e2b1cd0393": "디스크에서 파일을 읽을 수 없습니다: {{value0}}", + "b6cf20d514": "디스크의 파일이 바이너리입니다 — 텍스트 비교를 사용할 수 없습니다.", + "3fa2b8d417": "디스크에서 다시 로드", + "a95d02c644": "편집 내용 유지", + "2c8f1e07b9": "비교 로딩 중..." } }, "diff": { @@ -12771,27 +12823,27 @@ } }, "linear-issue-attribute-filter-dropdowns": { - "removeFilter": "Remove {{value0}} filter", - "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", - "filters": "Filters", - "allWorkspacesTitle": "Select one workspace", - "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", - "optionsFromTeam": "Options from {{team}}", - "clearAll": "Clear all filters" + "removeFilter": "{{value0}} 필터 제거", + "teamRequired": "이 워크트리의 상태, 담당자, 레이블을 로드하려면 팀을 선택하세요.", + "filters": "필터", + "allWorkspacesTitle": "워크트리 선택", + "allWorkspacesBody": "상태, 담당자, 레이블 필터는 단일 Linear �크트리의 ID를 사용합니다. 필터링할 워크트리를 선택하세요.", + "optionsFromTeam": "{{team}}의 옵션", + "clearAll": "모든 필터 지우기" }, "linear-issue-attribute-filter-sections": { - "status": "Status", - "priority": "Priority", - "assignee": "Assignee", - "unassigned": "Unassigned", - "labels": "Labels", - "countSelected": "{{count}} selected", - "selected": "selected", - "searchPriority": "Filter priority…", - "searchStatus": "Filter status…", - "searchLabels": "Filter labels…", - "searchAssignee": "Filter assignee…", - "back": "Back" + "status": "상태", + "priority": "우선순위", + "assignee": "담당자", + "unassigned": "미배정", + "labels": "레이블", + "countSelected": "{{count}}개 선택됨", + "selected": "선택됨", + "searchPriority": "우선순위 필터…", + "searchStatus": "상태 필터…", + "searchLabels": "레이블 필터…", + "searchAssignee": "담당자 필터…", + "back": "뒤로" } }, "i18n": { @@ -12825,9 +12877,9 @@ "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", - "imagePasteFailed": "Image paste failed.", - "worktreeNotReady": "Worktree not ready — try again in a moment.", - "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" + "imagePasteFailed": "이미지 붙여넣기 실패.", + "worktreeNotReady": "워크트리가 준비되지 않았습니다 — 잠시 후 다시 시도하세요.", + "uploadingAttachments": "{{value0}}개 파일을 원격으로 업로드 중…" }, "tool": { "running": "실행 중…", @@ -12880,7 +12932,7 @@ "allow": "허용하다", "deny": "부인하다" }, - "launchPromptNotDelivered": "Not delivered — check the terminal" + "launchPromptNotDelivered": "전달되지 않음 — 터미널을 확인하세요" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e4dd6de47ae..dfe9b6c8662 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -28,7 +28,8 @@ "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", - "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" + "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。", + "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" } } }, @@ -635,7 +636,7 @@ "94a5afe910": "SSH 主机", "40d80bad8a": "测试版", "de0c2907a1": "远程 Orca 服务器", - "b351014180": "Orca 统计数据以及 Claude、Codex 和 OpenCode 使用情况分析。", + "b351014180": "Orca 统计,以及 Claude、Codex、OpenCode Token 分析和 Grok 订阅使用量。", "d72a58b5b9": "统计和使用情况", "dcd0d9b74f": "常见操作的键盘快捷键。", "94295ebfb3": "快捷键", @@ -676,7 +677,7 @@ "cd50cec5d7": "通过 Orca 协调多个编码智能体。", "58a868e8e4": "编排", "7c79d3b7bf": "可选", - "b1c2f8b0ac": "Claude、Codex、Gemini 和 OpenCode Go 的可选账户切换。", + "b1c2f8b0ac": "Claude、Codex、Gemini、OpenCode Go、MiniMax 和 Grok 的可选账户切换与使用量设置。", "f70ac54d38": "AI 提供商账户", "4121f7a0a2": "管理 AI 智能体、设置默认值并自定义命令。", "b49abbd2f7": "智能体", @@ -1732,9 +1733,9 @@ "useIssueNumber": "使用议题 #{{value0}}", "noMatchingIssuesLoaded": "没有加载到匹配的议题。", "editReviewersWithCurrent": "编辑评审人:{{value0}}", - "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", - "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", - "linearFetchMore": "Fetch more" + "linearEmptyAttributeFilter": "没有匹配所选筛选条件的问题。请清除筛选条件或尝试其他条件。", + "linearEmptyUnfilteredScope": "此工作区范围内没有问题。请尝试搜索或调整团队。", + "linearFetchMore": "加载更多" }, "Terminal": { "73768427cf": "关闭", @@ -2651,9 +2652,9 @@ "title": "需要 SSH 连接", "connectingButton": "连接中...", "connectButton": "连接", - "removedTitle": "SSH host removed", - "removedBody": "The SSH host for this workspace was removed, so it can no longer connect. Remove the workspace to clear it — remote files are left untouched.", - "removeWorkspaceButton": "Remove workspace" + "removedTitle": "SSH 主机已移除", + "removedBody": "此工作区的 SSH 主机已被移除,无法再连接。请移除工作区以清理 — 远程文件不会受影响。", + "removeWorkspaceButton": "移除工作区" } } }, @@ -2863,7 +2864,7 @@ "a768f428f1": "关闭选项卡" }, "TerminalTabSplitMenuSection": { - "splitTerminal": "Split terminal" + "splitTerminal": "拆分终端" } } }, @@ -3032,7 +3033,9 @@ "c0e972d726": "取消", "06741a2f3d": "打开 MiniMax 使用量详情", "3bbf140864": "MiniMax 使用量", - "remoteServerLabel": "远程服务器" + "remoteServerLabel": "远程服务器", + "grokUsageAria": "打开 Grok 使用详情", + "grokUsageMenu": "Grok 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -3380,7 +3383,8 @@ "b2cf4310ce": "概览", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "概览" + "eee19cfade": "概览", + "grokUsageTab": "Grok" }, "UsageOverviewPane": { "22ed1b7669": "会话", @@ -3431,8 +3435,13 @@ "0bba8ca244": "统计数据", "0e2a0b6431": "用法", "372debfac0": "统计数据", - "26bb901fcd": "Orca 统计数据以及 Claude、Codex 和 OpenCode 的综合使用分析、Token、缓存、模型和会话。", - "cb2430ae6a": "统计和使用情况" + "26bb901fcd": "Orca 统计,以及 Claude、Codex、OpenCode Token 分析和 Grok 订阅使用量。", + "cb2430ae6a": "统计和使用情况", + "f8a1b2c3d4": "grok", + "e7f0a1b2c3": "订阅", + "d6e9f0a1b2": "额度", + "a3b6c7d8e9": "grok 使用量", + "9f2a3b4c5d": "xai" } }, "usage": { @@ -3482,6 +3491,17 @@ "faf3444859": "输入", "a8b7487ff7": "输出", "cfe2282ffa": "未知" + }, + "GrokUsagePane": { + "g8h9i0j1k2": "Grok 使用量", + "b2d3e4f5c6": "来自 Grok CLI OAuth(~/.grok/auth.json)的每周订阅额度。与状态栏使用同一来源。", + "c3e4f5a6b7": "在账户中设置", + "h9i0j1k2l3": " • {{value0}}", + "i0j1k2l3m4": "刷新 Grok 使用量", + "d4f5a6b7c8": "刷新", + "e5a6b7c8d9": "已用每周额度", + "f6b7c8d9e0": "计费周期重置", + "a7b8c9d0e1": "Grok 账户设置" } }, "sparse": { @@ -4179,7 +4199,7 @@ "hostDisconnected": "已断开连接", "failedNestWorkspace": "无法嵌套工作区", "sidebarRowMissing": "目标不再存在", - "failedUnnestWorkspace": "Failed to unnest workspace" + "failedUnnestWorkspace": "展开工作区失败" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -4497,14 +4517,14 @@ "7a8b9c0d1e": "打开设置", "8b9c0d1e2f": "删除主机", "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", - "oneWorkspace": "1 workspace", + "oneWorkspace": "1 个工作区", "manyWorkspaces": "{{count}} workspaces", "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", "alsoForgetLocal": "Also remove these {{value0}} from Orca", "advanced": "Advanced", - "alsoDeleteRemoteHint": "Permanently deletes the remote Git worktrees and their branches. Cannot be undone.", - "alsoForgetLocalHint": "Clears them from Orca only. Remote files, worktrees, and branches are left untouched." + "alsoDeleteRemoteHint": "永久删除远程 Git worktree 及其分支。此操作不可撤销。", + "alsoForgetLocalHint": "仅从 Orca 中清除。远程文件、worktree 和分支不受影响。" }, "HostRenameDialog": { "1a2b3c4d5e": "重命名主机", @@ -4619,14 +4639,14 @@ "sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。" }, "ForgetSshWorkspaceDialog": { - "reconnectFailed": "Reconnection failed", + "reconnectFailed": "重新连接失败", "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", "title": "Delete “{{name}}”?", - "disconnectedBody": "The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.", + "disconnectedBody": "此工作区的 SSH 主机未连接。重新连接以同时在远程删除,或仅从 Orca 中移除。", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", "cancel": "Cancel", - "forget": "Remove from Orca", - "reconnectAndDelete": "Reconnect & Delete" + "forget": "从 Orca 中移除", + "reconnectAndDelete": "重新连接并删除" } }, "shared": { @@ -4649,15 +4669,15 @@ "63520148e2": "{{value0}} 个会话拒绝退出。", "cc0a26cb14": "{{value0}} 个会话拒绝退出。", "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", - "2e57c1a940": "The daemon shutdown result is unverified because its management request failed.", - "993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.", - "1f0d8ac762": "Terminal cleanup finished with errors.", - "80b6ea14cf": "Terminal cleanup finished with warnings.", - "47cd2a50e9": "No sessions or terminal tabs were reported.", - "c34fb1098d": "Terminal tabs closed and shutdown requested.", - "d9657ac204": "Terminal session shutdown requested.", - "e8f25bd903": "Couldn’t finish terminal cleanup.", - "a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone." + "2e57c1a940": "守护进程关闭结果未经验证,因为其管理请求失败。", + "993af6052c": "守护进程管理报告已退出:{{value0}}/{{value1}};在精确清理前仍存在:{{value2}}。", + "1f0d8ac762": "终端清理完成,但有错误。", + "80b6ea14cf": "终端清理完成,但有警告。", + "47cd2a50e9": "未报告任何会话或终端标签页。", + "c34fb1098d": "终端标签页已关闭,关闭请求已发送。", + "d9657ac204": "终端会话关闭请求已发送。", + "e8f25bd903": "无法完成终端清理。", + "a702d4196e": "这将关闭所有工作区中的每个终端标签页,并请求关闭其当前终端会话。任何未保存的终端工作将丢失。守护进程本身将继续运行,可以立即打开新终端。此操作不可撤销。" } }, "setup": { @@ -4877,9 +4897,9 @@ "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手动", "3f1bdf3cb4": "环境文本过长,无法安全解析。", - "codexSessionSource": "Codex home to import from", - "codexSessionSourceInfo": "About importing Codex history", - "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." + "codexSessionSource": "要导入的 Codex 主目录", + "codexSessionSourceInfo": "关于导入 Codex 历史记录", + "codexSessionSourceTooltip": "Orca 在隔离的主目录中运行 Codex。将此项指向您现有的 Codex 主目录以导入会话历史记录。留空则使用 ~/.codex。" }, "AppIconSelector": { "d5a112dc9b": "下一个图标", @@ -6262,7 +6282,7 @@ "b5ee17826b": "配对远程 Orca 运行时,以获得持久会话、更丰富的远程状态,以及 Web 或移动端接续。", "7686cb5c36": "将此浏览器连接到已保存的 Orca 服务器。", "bd0181eeca": "远程 Orca 服务器", - "8acf3f22e0": "Orca 统计数据以及 Claude、Codex 和 OpenCode 使用情况分析。", + "8acf3f22e0": "Orca 统计,以及 Claude、Codex、OpenCode Token 分析和 Grok 订阅使用量。", "954a8f5aef": "统计和使用情况", "a737a4bb22": "常见操作的键盘快捷键。", "23bf7a1ad4": "快捷键", @@ -6932,7 +6952,13 @@ "b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。", "d09fb5ca92": "账户位置", "733f9e2a93": "MiniMax 使用量", - "f8374c3151": "粘贴 platform.minimax.io 会话 Cookie 以在本地获取速率限制。" + "f8374c3151": "粘贴 platform.minimax.io 会话 Cookie 以在本地获取速率限制。", + "f4a8c2e1b7": "Grok (xAI) 使用量", + "e3b7d1f9a2": "通过 Grok CLI(grok login)OAuth 登录以查看每周额度使用量。", + "d2c6a0e8f1": "grok", + "c1b5f9d7e0": "xai", + "b0a4e8c6d9": "OAuth", + "a9f3d7b5c8": "登录" } }, "advanced": { @@ -7146,7 +7172,11 @@ "detailed": "详细" }, "9a115966d3": "关闭时最小化到托盘", - "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。" + "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。", + "f8e2a1c4b6": "Grok 使用量", + "e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。", + "d6c0a9e2f4": "grok", + "c5b9f8d1e3": "xai" } }, "auto": { @@ -8471,7 +8501,7 @@ "supportedAgents": "此预设支持的智能体:{{value0}}。", "unsupportedSavedAgent": "{{value0}} 无法运行此文本生成预设。请在下方选择一个支持的智能体。", "resolveComments": "从选中的未解决 PR 或 MR 评论启动智能体。", - "fixPushFailure": "Start an agent when a pre-push hook or git push fails." + "fixPushFailure": "当 pre-push 钩子或 git push 失败时启动智能体。" } } } @@ -8847,12 +8877,12 @@ "notificationPlaygroundDescription": "用于检查提示布局、恢复操作和长文案换行的开发触发器。", "devOnly": "仅开发", "orcaCloud": "Orca Cloud", - "orcaCloudDescription": "Dev-only preview of first-party cloud sign-in. Hidden in production; in dev it also appears in the sidebar account switcher once ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID are set.", - "orcaCloudStatus": "Status", - "orcaCloudSignOut": "Sign out", - "orcaCloudConnect": "Connect profile", - "orcaCloudRefresh": "Refresh status", - "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build." + "orcaCloudDescription": "仅限开发环境的第一方云登录预览。在生产环境中隐藏;在开发环境中,设置 ORCA_CLOUD_API_URL 和 ORCA_CLOUD_CLIENT_ID 后也会出现在侧边栏账户切换器中。", + "orcaCloudStatus": "状态", + "orcaCloudSignOut": "退出登录", + "orcaCloudConnect": "连接配置文件", + "orcaCloudRefresh": "刷新状态", + "orcaCloudNotConfigured": "设置 ORCA_CLOUD_API_URL 和 ORCA_CLOUD_CLIENT_ID 以在此开发版本中预览 Orca Cloud 登录。" }, "EphemeralVmRecipeRow": { "useInWorkspace": "在工作区中使用" @@ -8908,36 +8938,51 @@ "toggleLabel": "切换工作区专属环境" }, "SourceControlActionRepoOverrideNote": { - "agent": "Agent", - "agentArgs": "CLI arguments", - "commandTemplate": "Command template", - "more": "+{{count}} more", - "plural": "Global saves won't change {{count}} repositories with their own recipes.", - "recipe": "Recipe", - "review": "Review", - "reviewFirst": "Review first", - "singular": "Global saves won't change 1 repository with its own recipe.", - "tooltipTitle": "Repository overrides" + "agent": "智能体", + "agentArgs": "CLI 参数", + "commandTemplate": "命令模板", + "more": "+{{count}} 更多", + "plural": "全局保存不会更改 {{count}} 个拥有自己配置的仓库。", + "recipe": "配置", + "review": "审查", + "reviewFirst": "先审查", + "singular": "全局保存不会更改 1 个拥有自己配置的仓库。", + "tooltipTitle": "仓库覆盖" }, "linear": { "agent": { "skill": { "install": { "cta": { - "copiedCommand": "Copied command.", - "copyFailed": "Failed to copy command.", - "skillLabel": "Agent skill:", - "checking": "Checking...", - "installed": "Installed", - "notInstalled": "Not installed", - "recheck": "Re-check", - "installedDescription": "Agent skill installed. To update it, run:", - "description": "Let your agents read and edit Linear tasks.", - "copyCommand": "Copy command" + "copiedCommand": "命令已复制。", + "copyFailed": "复制命令失败。", + "skillLabel": "智能体技能:", + "checking": "检查中...", + "installed": "已安装", + "notInstalled": "未安装", + "recheck": "重新检查", + "installedDescription": "智能体技能已安装。要更新,请运行:", + "description": "让您的智能体读取和编辑 Linear 任务。", + "copyCommand": "复制命令" } } } } + }, + "GrokAccountsSection": { + "a1b2c3d4e5": "Grok (xAI)", + "f6e5d4c3b2": "显示来自 Grok CLI 登录的每周额度使用量(会话文件 ~/.grok/auth.json)。", + "0d8e77bc40": "Grok CLI 文档", + "ad47a33f72": "正在加载…", + "b2c3d4e5f6": "已登录", + "c3d4e5f6a7": "已登录。Orca 只读取磁盘上的该文件;如果使用量获取失败,请重新运行 grok login。", + "d4e5f6a7b8": "会话已过期,请在终端运行 grok login 以刷新。", + "e5f6a7b8c9": "未登录 Grok CLI", + "f6a7b8c9d0": "在终端运行 grok login,然后点击此处的刷新使用量。", + "3325d996cb": "刷新使用量", + "a8f3e2c1b4": "每周额度", + "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", + "c6d1a8f4e2": "{{when}} 重置" } }, "right": { @@ -9406,22 +9451,22 @@ "c1a8f3e204": "落后 {{value0}} 1 个提交", "d2b9g4f315": "落后 {{value1}} {{value0}} 个提交", "e9c2a5d416": "与 {{value0}} 同步", - "4b4a7de138": "Open review page in browser", - "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR.", + "4b4a7de138": "在浏览器中打开审查页面", + "createPrIntentCommitBlockedSummary": "提交被阻止:{{value0}} 修复问题后重试创建 PR。", "pushRecovery": { - "4b37ae99b0": "Start the default AI agent to fix this push failure", - "30b8d4f181": "Fix push failure with AI", - "dd43c47089": "Choose an agent for this push failure", - "ec7bfced55": "Choose agent to fix push failure", - "9e5ccd00aa": "Push failure context unavailable", - "054ead86b1": "Fix Push Failure With AI", - "15b7f210d7": "Choose the agent and edit the full command input before launch.", - "011f9713fc": "Push blocked", - "60bd988f0b": "AI Fix", - "03d238218c": "Details", - "a9bf7c171a": "Push Failed", - "834cb3f23d": "Fix with AI", - "783a808870": "Close" + "4b37ae99b0": "启动默认 AI 智能体修复此推送失败", + "30b8d4f181": "使用 AI 修复推送失败", + "dd43c47089": "选择智能体修复此推送失败", + "ec7bfced55": "选择智能体修复推送失败", + "9e5ccd00aa": "推送失败上下文不可用", + "054ead86b1": "使用 AI 修复推送失败", + "15b7f210d7": "选择智能体并在启动前编辑完整命令输入。", + "011f9713fc": "推送已阻止", + "60bd988f0b": "AI 修复", + "03d238218c": "详情", + "a9bf7c171a": "推送失败", + "834cb3f23d": "使用 AI 修复", + "783a808870": "关闭" } }, "SourceControlAgentActionDialog": { @@ -9675,30 +9720,30 @@ "push": { "failure": { "launch": { - "216f762bd7": "Unable to resolve the workspace connection.", - "4f4e0418a0": "Could not build the agent prompt.", - "f2b47026e8": "Push failure prompt is empty. Update Source Control AI settings.", - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "a8b97d2318": "Started an AI agent for the push failure." + "216f762bd7": "无法解析工作区连接。", + "4f4e0418a0": "无法构建智能体提示词。", + "f2b47026e8": "推送失败提示词为空。请更新源代码控制 AI 设置。", + "d481ab22f9": "已保存的 AI 智能体不可用。请使用「自定义启动」选择其他智能体。", + "9bbd9077a2": "没有已启用的 AI 智能体。请在设置中配置智能体。", + "5540ff50cc": "无法构建智能体启动命令。", + "a8b97d2318": "已为推送失败启动 AI 智能体。" } } }, "recovery": { "launch": { - "4f4e0418a0": "Could not build the agent prompt.", + "4f4e0418a0": "无法构建智能体提示词。", "push": { - "empty": "Push failure prompt is empty. Update Source Control AI settings." + "empty": "推送失败提示词为空。请更新源代码控制 AI 设置。" }, "commit": { - "empty": "Commit failure prompt is empty. Update Source Control AI settings." + "empty": "提交失败提示词为空。请更新源代码控制 AI 设置。" }, - "d481ab22f9": "Saved AI agent is unavailable. Use Customize launch to choose another agent.", - "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", - "5540ff50cc": "Could not build the agent launch command.", - "216f762bd7": "Unable to resolve the workspace connection.", - "success": "Started an AI agent for the {{value0}} failure." + "d481ab22f9": "已保存的 AI 智能体不可用。请使用「自定义启动」选择其他智能体。", + "9bbd9077a2": "没有已启用的 AI 智能体。请在设置中配置智能体。", + "5540ff50cc": "无法构建智能体启动命令。", + "216f762bd7": "无法解析工作区连接。", + "success": "已为 {{value0}} 失败启动 AI 智能体。" } } }, @@ -9883,7 +9928,7 @@ "currentProjectLower": "当前项目", "project": "项目", "hostScopeAriaLabel": "Session History host: {{value0}}", - "host": "Host" + "host": "主机" }, "AiVaultSessionDetails": { "updated": "更新时间", @@ -9968,7 +10013,9 @@ "sessionRole": "会话", "jumpToOriginalPane": "跳转到原始窗格", "jumpToWorktree": "跳转到工作树", - "recoverableBadge": "未保存" + "recoverableBadge": "未保存", + "subagentCountSingular": "1 subagent", + "subagentCountPlural": "{{value0}} subagents" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", @@ -10023,6 +10070,11 @@ "archivedJumpUnavailable": "此会话位于已归档的工作树中。", "noActiveWorktreeMatch": "没有与此会话匹配的活动工作树。", "noActiveWorktreeTarget": "没有可用的活动工作树。" + }, + "AiVaultSessionSubagents": { + "subagentsCount": "Subagents ({{value0}})", + "messageCount": "{{value0}} msgs", + "viewLog": "View Log" } } }, @@ -10587,7 +10639,7 @@ }, "FloatingTerminalToggleButton": { "3b04b065b5": "显示浮动工作区", - "4cb418b991": "Show floating workspace, new activity", + "4cb418b991": "显示浮动工作区,有新活动", "5785dd9148": "最小化浮动工作区", "bfe7809a70": "{{value0}} 浮动工作区 ({{value1}})" }, @@ -11751,22 +11803,22 @@ "5a8e1d4f23": " · " }, "ExternalFileChangeBanner": { - "7c41e90d12": "This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "5c02de9b31": "Reloaded from disk", - "d1e830fa22": "Undo", - "90b2ce7d43": "Compare" + "7c41e90d12": "此文件在您有未保存编辑时在磁盘上发生了更改。保存将覆盖较新的磁盘内容。", + "3fa2b8d417": "从磁盘重新加载", + "a95d02c644": "保留我的编辑", + "5c02de9b31": "已从磁盘重新加载", + "d1e830fa22": "撤销", + "90b2ce7d43": "比较" }, "ExternalFileChangeCompareDialog": { - "4b8de20a11": "File changed on disk", - "90cc31e4d7": "Disk version on the left, your unsaved edits on the right.", - "8fe30ab254": "Reading file from disk...", - "e2b1cd0393": "Could not read the file from disk: {{value0}}", - "b6cf20d514": "The file on disk is binary — no text comparison available.", - "3fa2b8d417": "Reload from Disk", - "a95d02c644": "Keep My Edits", - "2c8f1e07b9": "Loading comparison..." + "4b8de20a11": "文件在磁盘上已更改", + "90cc31e4d7": "左侧为磁盘版本,右侧为您的未保存编辑。", + "8fe30ab254": "正在从磁盘读取文件...", + "e2b1cd0393": "无法从磁盘读取文件:{{value0}}", + "b6cf20d514": "磁盘上的文件是二进制文件 — 无法进行文本比较。", + "3fa2b8d417": "从磁盘重新加载", + "a95d02c644": "保留我的编辑", + "2c8f1e07b9": "正在加载比较..." } }, "diff": { @@ -12455,7 +12507,7 @@ "fdd26d81cc": "Atlassian 账户设置", "8090504a3e": "在此创建 token:", "7b3967c12f": "Atlassian API token", - "3d81bf3ab3": "API token", + "3d81bf3ab3": "API 令牌", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian 邮箱", "70fcd360c4": "https://example.atlassian.net", @@ -12771,27 +12823,27 @@ } }, "linear-issue-attribute-filter-dropdowns": { - "removeFilter": "Remove {{value0}} filter", - "teamRequired": "Select a team to load status, assignees, and labels for this workspace.", - "filters": "Filters", - "allWorkspacesTitle": "Select one workspace", - "allWorkspacesBody": "Status, assignee, and label filters use ids from a single Linear workspace. Choose one workspace to filter by those attributes.", - "optionsFromTeam": "Options from {{team}}", - "clearAll": "Clear all filters" + "removeFilter": "移除 {{value0}} 筛选器", + "teamRequired": "选择一个团队以加载此工作区的状态、负责人和标签。", + "filters": "筛选器", + "allWorkspacesTitle": "选择一个工作区", + "allWorkspacesBody": "状态、负责人和标签筛选器使用单个 Linear 工作区的 ID。请选择一个工作区以按这些属性进行筛选。", + "optionsFromTeam": "来自 {{team}} 的选项", + "clearAll": "清除所有筛选器" }, "linear-issue-attribute-filter-sections": { - "status": "Status", - "priority": "Priority", - "assignee": "Assignee", - "unassigned": "Unassigned", - "labels": "Labels", - "countSelected": "{{count}} selected", - "selected": "selected", - "searchPriority": "Filter priority…", - "searchStatus": "Filter status…", - "searchLabels": "Filter labels…", - "searchAssignee": "Filter assignee…", - "back": "Back" + "status": "状态", + "priority": "优先级", + "assignee": "负责人", + "unassigned": "未分配", + "labels": "标签", + "countSelected": "已选择 {{count}} 个", + "selected": "已选择", + "searchPriority": "筛选优先级…", + "searchStatus": "筛选状态…", + "searchLabels": "筛选标签…", + "searchAssignee": "筛选负责人…", + "back": "返回" } }, "i18n": { @@ -12825,9 +12877,9 @@ "localAttachmentUnsupported": "远程会话不支持本地附件。", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", - "imagePasteFailed": "Image paste failed.", - "worktreeNotReady": "Worktree not ready — try again in a moment.", - "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" + "imagePasteFailed": "图片粘贴失败。", + "worktreeNotReady": "Worktree 未就绪 — 请稍后重试。", + "uploadingAttachments": "正在上传 {{value0}} 个文件到远程…" }, "tool": { "running": "正在运行…", @@ -12880,7 +12932,7 @@ "allow": "允许", "deny": "拒绝" }, - "launchPromptNotDelivered": "Not delivered — check the terminal" + "launchPromptNotDelivered": "未送达 — 请检查终端" }, "tab": { "bar": { diff --git a/src/renderer/src/lib/agent-row-primary-text.test.ts b/src/renderer/src/lib/agent-row-primary-text.test.ts index b0360588c59..e6a0094a94a 100644 --- a/src/renderer/src/lib/agent-row-primary-text.test.ts +++ b/src/renderer/src/lib/agent-row-primary-text.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' +import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' import { getAgentRowGeneratedTitleText, getAgentRowPrimaryText, + getOrcaDispatchTaskId, isOrcaDispatchPrompt } from './agent-row-primary-text' @@ -72,6 +74,58 @@ Keep the raw preamble out of the sidebar.` it('falls back to the raw prompt outside orchestration workers', () => { expect(getAgentRowPrimaryText({ prompt: 'Fix checkout race' })).toBe('Fix checkout race') }) + + // Why: production status prompts reach these helpers already folded to a + // single line and capped at 200 chars by normalizeAgentStatusPayload. Guard + // the real normalized shape — earlier tests only fed raw multi-line prompts, + // which hid that the task-id parser split on \n and never matched. + it('matches orchestration labels on a normalized single-line dispatch prompt', () => { + const normalized = normalizeAgentStatusPayload({ + state: 'working', + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your coordinator's terminal handle is: term_parent +Your task ID is: task_9f3ab2 + +You talk to the coordinator only through the CLI commands below. + +=== TASK === +Fix the checkout race condition in payments` + }) + expect(normalized).not.toBeNull() + expect( + getAgentRowPrimaryText({ + prompt: normalized!.prompt, + orchestration: { + taskId: 'task_9f3ab2', + dispatchId: 'ctx-1', + taskTitle: 'Checkout race', + displayName: 'Fix checkout race' + } + }) + ).toBe('Fix checkout race') + }) +}) + +describe('getOrcaDispatchTaskId', () => { + it('extracts the id when the trailing newline was folded to a space', () => { + const normalized = normalizeAgentStatusPayload({ + state: 'working', + prompt: `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. +Your task ID is: task_9f3ab2 + +=== TASK === +body` + }) + expect(getOrcaDispatchTaskId(normalized!.prompt)).toBe('task_9f3ab2') + }) + + it('extracts the id from a raw multi-line prompt', () => { + expect( + getOrcaDispatchTaskId( + `You are working inside Orca, a multi-agent IDE.\nYour task ID is: task_1\n\n=== TASK ===\nbody` + ) + ).toBe('task_1') + }) }) describe('isOrcaDispatchPrompt / getAgentRowGeneratedTitleText', () => { diff --git a/src/renderer/src/lib/agent-row-primary-text.ts b/src/renderer/src/lib/agent-row-primary-text.ts index 931dd1fbbdc..b75bfafb0b4 100644 --- a/src/renderer/src/lib/agent-row-primary-text.ts +++ b/src/renderer/src/lib/agent-row-primary-text.ts @@ -84,9 +84,13 @@ export function getOrcaDispatchTaskId(prompt: string): string | null { if (markerIndex === -1) { return null } - const afterMarker = scan.slice(markerIndex + ORCA_DISPATCH_TASK_ID_MARKER.length) - const lineEnd = afterMarker.search(/\r|\n/) - const idLine = (lineEnd === -1 ? afterMarker : afterMarker.slice(0, lineEnd)).trim() + // Why: delimit on the first whitespace, not just a newline. The task id is a + // whitespace-free token, and by the time this parses a live status prompt the + // trailing newline has been folded to a space by normalizeSingleLinePreview — + // splitting on \n alone would return the id plus the rest of the preamble. + const afterMarker = scan.slice(markerIndex + ORCA_DISPATCH_TASK_ID_MARKER.length).trimStart() + const idEnd = afterMarker.search(/\s/) + const idLine = idEnd === -1 ? afterMarker : afterMarker.slice(0, idEnd) return idLine || null } diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 4513119ab69..37bd643833a 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -6,6 +6,7 @@ export type RateLimitSlice = { rateLimits: RateLimitState fetchRateLimits: () => Promise refreshRateLimits: () => Promise + refreshGrokRateLimits: () => Promise refreshClaudeRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise refreshCodexRateLimitsForTarget: (target: RateLimitRuntimeTarget) => Promise consumeCodexRateLimitResetCredit: () => Promise @@ -22,7 +23,9 @@ export const createRateLimitSlice: StateCreator { + try { + const state = await window.api.rateLimits.refreshGrok() + set({ rateLimits: state }) + } catch (error) { + console.error('Failed to refresh Grok usage:', error) + } + }, + refreshClaudeRateLimitsForTarget: async (target) => { const current = get().rateLimits const targetChanged = diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 202adfe5e31..6c47388db03 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1215,13 +1215,15 @@ describe('createUISlice hydratePersistedUI', () => { 'resource-usage', 'ports', 'kimi', - 'minimax' + 'minimax', + 'grok' ]) expect(setUI).toHaveBeenCalledWith({ - statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax'], + statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax', 'grok'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true + _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true }) }) @@ -1235,7 +1237,8 @@ describe('createUISlice hydratePersistedUI', () => { statusBarItems: ['claude', 'resource-usage'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true + _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 7b280b7b100..00f40ee2f3d 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -263,6 +263,7 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' +const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) @@ -2257,18 +2258,24 @@ export const createUISlice: StateCreator = (set, get) ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') ? statusBarItems : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] + const statusBarItemsWithGrok = + ui._grokStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('grok') + ? statusBarItemsWithMiniMax + : [...statusBarItemsWithMiniMax, DEFAULT_ON_GROK_STATUS_BAR_ITEM] if ( (!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded || - !ui._minimaxStatusBarDefaultAdded) && + !ui._minimaxStatusBarDefaultAdded || + !ui._grokStatusBarDefaultAdded) && typeof window !== 'undefined' ) { window.api.ui .set({ - statusBarItems: statusBarItemsWithMiniMax, + statusBarItems: statusBarItemsWithGrok, _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true + _minimaxStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true }) .catch(console.error) } @@ -2333,7 +2340,7 @@ export const createUISlice: StateCreator = (set, get) workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, - statusBarItems: statusBarItemsWithMiniMax, + statusBarItems: statusBarItemsWithGrok, statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 0e144736b5f..37cf9a18149 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -704,6 +704,7 @@ function createWebPreloadApi(): Partial { notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), + grokAccounts: createGrokAccountsApi(), codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), @@ -1201,6 +1202,10 @@ function createAiVaultApi(): NonNullable['aiVault']> { executionHostId }) }, + // Why: the runtime RPC surface only exposes aiVault.listSessions; subagent + // transcript listing has no server-side method yet, so the browser client + // reports an empty (not erroring) result. + listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), onWindowFocused: () => noopUnsubscribe } } @@ -2584,7 +2589,9 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { opencodeGo: null, kimi: null, minimax: null, + grok: null, minimaxCookieConfigured: false, + grokAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], @@ -2602,6 +2609,7 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), refreshMiniMax: () => Promise.resolve(empty), + refreshGrok: () => Promise.resolve(empty), onUpdate: () => noopUnsubscribe } } @@ -2616,6 +2624,19 @@ function createMiniMaxCredentialsApi(): NonNullable['minimax } } +function createGrokAccountsApi(): NonNullable['grokAccounts']> { + const unsigned = { + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: null + } + return { + getStatus: () => Promise.resolve(unsigned) + } +} + function createAccountsApi(): never { const empty = { accounts: [], diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 0696f770a1c..b0622da0481 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -54,6 +54,18 @@ export type AiVaultSessionPreviewMessage = { timestamp: string | null } +// Terminal statuses come from records in the parent +// transcript; 'running' is inferred from recent transcript activity. +export type AiVaultSubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped' + +// Set only on Task subagent transcript rows (listed on demand under their +// parent session); null for every top-level scanned session. +export type AiVaultSessionSubagentInfo = { + parentSessionId: string + agentType: string | null + status: AiVaultSubagentRunStatus | null +} + export type AiVaultSession = { id: string executionHostId: ExecutionHostId @@ -73,11 +85,28 @@ export type AiVaultSession = { totalTokens: number previewMessages: AiVaultSessionPreviewMessage[] // Recoverable signal for sessions whose conversation transcript persisted zero - // user/assistant turns: queued (never-flushed) prompts and sibling subagent - // transcripts survive even when the main conversation was lost. + // user/assistant turns: queued (never-flushed) prompts survive even when the + // main conversation was lost. queuedMessageCount: number + // Number of Task subagent transcripts stored beside this session; always 0 + // for agents that don't materialize subagent transcripts. Doubles as the + // recoverable signal for zero-turn sessions. subagentTranscriptCount: number resumeCommand: string + subagent: AiVaultSessionSubagentInfo | null +} + +export type AiVaultSubagentListArgs = { + agent: AiVaultAgent + parentFilePath: string + // The session's host. Subagent transcripts are read from the local + // filesystem, so non-local hosts resolve to an empty list. + executionHostId?: ExecutionHostId +} + +export type AiVaultSubagentListResult = { + sessions: AiVaultSession[] + issues: AiVaultScanIssue[] } // A session is only offered for normal resume when its transcript actually holds diff --git a/src/shared/git-clone-failure-message.test.ts b/src/shared/git-clone-failure-message.test.ts index 2556b924818..71ca28617e7 100644 --- a/src/shared/git-clone-failure-message.test.ts +++ b/src/shared/git-clone-failure-message.test.ts @@ -46,6 +46,18 @@ describe('getGitCloneFailureMessage', () => { ) }) + it('scrubs credential-bearing clone URLs before surfacing the fatal line', () => { + // Clone errors echo the URL the user typed — the most likely git error to + // embed a live token — and the message reaches dialogs and bug reports. + const stderr = + 'Cloning into repo...\n' + + "fatal: repository 'https://user:ghp_secret123@github.com/org/repo.git/' not found\n" + + expect(getGitCloneFailureMessage(stderr)).toBe( + "fatal: repository 'https://github.com/org/repo.git/' not found" + ) + }) + it('summarizes CRLF-heavy stderr without line-array splitting', () => { const splitSpy = vi.spyOn(String.prototype, 'split') const stderr = `${'remote: counting objects\r\n'.repeat(10_000)}fatal: repository not found\r\n` diff --git a/src/shared/git-clone-failure-message.ts b/src/shared/git-clone-failure-message.ts index 42fe996a7fc..44223dad59f 100644 --- a/src/shared/git-clone-failure-message.ts +++ b/src/shared/git-clone-failure-message.ts @@ -1,10 +1,18 @@ +import { stripCredentialsFromMessage } from './git-remote-error' + export function getGitCloneFailureMessage( stderr: string, options: { clonePath?: string | null } = {} ): string { let fallbackLine: string | null = null - for (const rawLine of iterateLinesFromEnd(stderr)) { + // Why: clone errors echo the URL the user typed, which is the most likely + // git error to embed a live token (`https://user:ghp_…@host/repo.git`). + // Scrub up-front so every return branch operates on already-redacted text, + // matching normalizeGitErrorMessage. + const scrubbedStderr = stripCredentialsFromMessage(stderr) + + for (const rawLine of iterateLinesFromEnd(scrubbedStderr)) { const line = stripAnsi(rawLine).trim() if (!line) { continue diff --git a/src/shared/git-output-locale.ts b/src/shared/git-output-locale.ts new file mode 100644 index 00000000000..7711399892e --- /dev/null +++ b/src/shared/git-output-locale.ts @@ -0,0 +1,16 @@ +/** + * Env pinning git subprocess output to untranslated English (issue #7808). + * + * Why: Orca parses git's stderr diagnostics and progress lines (e.g. + * isNoUpstreamError, clone progress/`fatal:` matching); a gettext-enabled git + * under a non-English locale translates even the `fatal:` prefix and silently + * breaks those parsers. An English UTF-8 locale rather than plain `C` keeps a + * UTF-8 LC_CTYPE for hooks git spawns (commit, push); a host without + * en_US.UTF-8 falls back to C, which still emits untranslated messages. + * LANGUAGE outranks LC_ALL in gettext's lookup, so it is pinned too. + */ +export const UNTRANSLATED_GIT_OUTPUT_ENV = { + LANGUAGE: 'en', + LC_ALL: 'en_US.UTF-8', + LANG: 'en_US.UTF-8' +} as const diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index dfc8c1dad31..255965ec034 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -14,7 +14,9 @@ describe('RateLimitState', () => { opencodeGo: null, kimi: null, minimax: null, + grok: null, minimaxCookieConfigured: false, + grokAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 6ed2c696f1b..d19699dee01 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -95,6 +95,14 @@ export type InactiveAccountUsage = { isFetching: boolean } +export type GrokAccountStatus = { + signedIn: boolean + email: string | null + teamId: string | null + tokenFresh: boolean + error: string | null +} + export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null @@ -102,6 +110,7 @@ export type RateLimitState = { opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives * outside GlobalSettings, so this flag is the durable signal that the @@ -109,6 +118,8 @@ export type RateLimitState = { * between snapshot refreshes. */ minimaxCookieConfigured: boolean + /** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */ + grokAuthConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 5e9939c6461..0d48a6364f7 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -7,6 +7,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'opencode-go', 'kimi', 'minimax', + 'grok', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/terminal-line-height-settings.test.ts b/src/shared/terminal-line-height-settings.test.ts new file mode 100644 index 00000000000..6872f846529 --- /dev/null +++ b/src/shared/terminal-line-height-settings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_TERMINAL_LINE_HEIGHT, + MIN_TERMINAL_LINE_HEIGHT, + normalizeTerminalLineHeight +} from './terminal-line-height-settings' + +describe('normalizeTerminalLineHeight', () => { + it.each([ + [undefined, MIN_TERMINAL_LINE_HEIGHT], + [Number.NaN, MIN_TERMINAL_LINE_HEIGHT], + [0.85, MIN_TERMINAL_LINE_HEIGHT], + [1, 1], + [1.35, 1.35], + [3, 3], + [4, MAX_TERMINAL_LINE_HEIGHT] + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeTerminalLineHeight(input)).toBe(expected) + }) +}) diff --git a/src/shared/terminal-line-height-settings.ts b/src/shared/terminal-line-height-settings.ts new file mode 100644 index 00000000000..210b8f4e11b --- /dev/null +++ b/src/shared/terminal-line-height-settings.ts @@ -0,0 +1,11 @@ +export const MIN_TERMINAL_LINE_HEIGHT = 1 +export const MAX_TERMINAL_LINE_HEIGHT = 3 + +export function normalizeTerminalLineHeight(value: unknown): number { + // Why: older or user-edited profiles can bypass the UI clamp, and xterm + // throws during construction when lineHeight is below one. + if (typeof value !== 'number' || !Number.isFinite(value)) { + return MIN_TERMINAL_LINE_HEIGHT + } + return Math.min(MAX_TERMINAL_LINE_HEIGHT, Math.max(MIN_TERMINAL_LINE_HEIGHT, value)) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 440db2750d2..d10fbba15df 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3156,6 +3156,7 @@ export type StatusBarItem = | 'opencode-go' | 'kimi' | 'minimax' + | 'grok' | 'ssh' | 'resource-usage' | 'ports' @@ -3271,6 +3272,8 @@ export type PersistedUIState = { _kimiStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on MiniMax status item. */ _minimaxStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Grok status item. */ + _grokStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean dismissedUpdateVersion: string | null diff --git a/src/shared/wsl-hook-relay-contract.ts b/src/shared/wsl-hook-relay-contract.ts new file mode 100644 index 00000000000..ea1d763d657 --- /dev/null +++ b/src/shared/wsl-hook-relay-contract.ts @@ -0,0 +1,88 @@ +// Shared contract between the Windows host and the guest-resident WSL +// agent-hook relay. Both sides derive paths/methods from here so the guest +// process and the host manager can never drift on where the relay lives, +// which JSON-RPC methods the fs bridge speaks, or which exit codes signal +// "reinstall me" vs "no usable node". +// See docs/agent-status-over-wsl.md (STA-1515). + +/** Guest-side install dir for the relay bundle, relative to `$HOME`. */ +export const WSL_HOOK_RELAY_DIR = '.orca-wsl/hook-relay' +export const WSL_HOOK_RELAY_BUNDLE_NAME = 'wsl-agent-hook-relay.js' +export const WSL_HOOK_RELAY_VERSION_FILE = '.version' + +/** Host-expected bundle version, crossed into the guest launch script via + * WSLENV so a stale guest install is detected by the guest itself. Also + * namespaces the guest install dir, so concurrent Orca instances with + * different bundle versions (dev + prod) never reinstall over each other. */ +export const WSL_HOOK_RELAY_VERSION_ENV = 'ORCA_WSL_HOOK_RELAY_VERSION' + +/** Stable per-instance identity for the guest endpoint dir, crossed via + * WSLENV. Derived from the Windows endpoint file path (userData + + * namespace), NOT the hook port: the port changes every app launch, and a + * port-keyed dir would leave daemon-surviving agents sourcing a stale file + * after an Orca restart — the exact re-coordination this exists to serve. */ +export const WSL_HOOK_RELAY_INSTANCE_ENV = 'ORCA_WSL_HOOK_INSTANCE' + +/** Launch-script exit codes. 42 mirrors the SSH relay's handshake-mismatch + * convention: the host reinstalls the bundle and relaunches once. */ +export const WSL_HOOK_RELAY_STALE_EXIT_CODE = 42 +export const WSL_HOOK_RELAY_NO_NODE_EXIT_CODE = 43 + +/** JSON-RPC methods for the relay's home-scoped fs bridge. The host runs the + * unchanged SSH remote hook installers against these via an SFTP-shaped + * adapter, so hook installation rides the already-open stdio channel instead + * of per-file wsl.exe spawns. */ +export const WSL_HOOK_FS_METHODS = { + home: 'wslfs.home', + readFile: 'wslfs.readFile', + writeFile: 'wslfs.writeFile', + stat: 'wslfs.stat', + rename: 'wslfs.rename', + unlink: 'wslfs.unlink', + chmod: 'wslfs.chmod', + readdir: 'wslfs.readdir', + mkdir: 'wslfs.mkdir' +} as const + +/** Result envelope for every fs-bridge method. Errors travel as data (not + * JSON-RPC faults) so the host adapter can map POSIX errno onto the ssh2 + * status codes the shared installer error-classifiers already understand. */ +export type WslFsFailure = { ok: false; errno: string; message: string } +export type WslFsResult = ({ ok: true } & T) | WslFsFailure + +/** Where the guest relay publishes its endpoint file. Keyed by the stable + * instance key (restart-stable, instance-unique), so a restarted instance's + * relay REWRITES the same file that surviving agents' env already names — + * that rewrite is what re-coordinates them onto fresh port/token. */ +export function wslHookRelayEndpointDir(guestHome: string, instanceKey: string): string { + const home = guestHome.endsWith('/') ? guestHome.slice(0, -1) : guestHome + return `${home}/.orca-wsl/agent-hooks/instance-${instanceKey}` +} + +/** Keep instance keys shell/path-inert on both sides of the boundary. */ +export function sanitizeWslHookInstanceKey(value: string | undefined): string | null { + const trimmed = value?.trim().toLowerCase() ?? '' + return /^[a-z0-9][a-z0-9-]{0,63}$/.test(trimmed) ? trimmed : null +} + +/** The guest is always POSIX, so the Windows host must name the guest's + * endpoint file explicitly — its own `getEndpointFileName()` would say + * `endpoint.cmd`. Matches the POSIX branch of that helper. */ +export const WSL_HOOK_RELAY_ENDPOINT_FILE = 'endpoint.env' + +/** connectionId stamped on WSL-relayed hook envelopes. Transport provenance + * only: the pane is a LOCAL pane on a local repo, so ownership checks must + * treat these ids as local (null), not as a remote connection. */ +export const WSL_HOOK_RELAY_CONNECTION_PREFIX = 'wsl:' + +export function wslHookRelayConnectionId(distro: string): string { + return `${WSL_HOOK_RELAY_CONNECTION_PREFIX}${distro}` +} + +export function isWslHookRelayConnectionId(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(WSL_HOOK_RELAY_CONNECTION_PREFIX) +} + +export function wslHookRelayEndpointFilePath(guestHome: string, instanceKey: string): string { + return `${wslHookRelayEndpointDir(guestHome, instanceKey)}/${WSL_HOOK_RELAY_ENDPOINT_FILE}` +}