From a6311f24f36de0a55358d52acd7d9e63f35fe2f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:51:13 -0700 Subject: [PATCH] feat(agent-hooks): support Prime Agent status in WSL --- .../wsl-hook-relay-manager.test.ts | 7 + .../agent-hooks/wsl-hook-relay-manager.ts | 12 +- src/main/daemon/shell-ready.test.ts | 5 + src/main/daemon/shell-ready.ts | 4 + src/main/ipc/pty.test.ts | 36 ++- src/main/ipc/pty.ts | 30 ++- .../pi/agent-status-extension-source.test.ts | 33 +++ src/main/pi/agent-status-extension-source.ts | 84 +----- src/main/pi/agent-status-wsl-curl-source.ts | 81 ++++++ .../pi/titlebar-extension-service.test.ts | 19 ++ src/main/pi/titlebar-extension-service.ts | 22 +- .../providers/local-pty-shell-ready.test.ts | 5 + src/main/providers/local-pty-shell-ready.ts | 4 + src/main/providers/windows-shell-args.test.ts | 7 +- .../pty/prime-agent-shell-wrapper.test.ts | 244 ++++++++++++++++++ src/main/pty/prime-agent-shell-wrapper.ts | 74 ++++++ src/main/pty/wsl-orca-env.test.ts | 5 + src/main/pty/wsl-orca-env.ts | 1 + 18 files changed, 575 insertions(+), 98 deletions(-) create mode 100644 src/main/pi/agent-status-wsl-curl-source.ts create mode 100644 src/main/pty/prime-agent-shell-wrapper.test.ts create mode 100644 src/main/pty/prime-agent-shell-wrapper.ts diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 4d2ae9b7bb5..cb538595c19 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -233,6 +233,13 @@ describe('WslHookRelayManager', () => { return { manager: new WslHookRelayManager(deps), deps } } + it('exposes the stable guest endpoint instance key with a port fallback', () => { + expect(createManager({}).manager.getInstanceKey()).toBe('testinstance') + expect(createManager({ instanceKey: () => 'unsafe/key' }).manager.getInstanceKey()).toBe( + 'port43117' + ) + }) + it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro('Ubuntu') diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 74d163ca42f..828e2fa888a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -109,6 +109,15 @@ export class WslHookRelayManager { return this.stateFor(distro)?.opencodeOverlayDir ?? null } + getInstanceKey(): string | null { + const stableKey = sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) + if (stableKey) { + return stableKey + } + const port = Number(this.deps.hookCoordsEnv().ORCA_AGENT_HOOK_PORT ?? '') + return Number.isInteger(port) && port > 0 ? `port${port}` : null + } + /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the * manager reusable, so re-enabling hooks can start relays again without an app restart. */ disposeAll({ permanent = true }: { permanent?: boolean } = {}): void { @@ -172,8 +181,7 @@ export class WslHookRelayManager { } // 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}` + const instanceKey = this.getInstanceKey() ?? `port${port}` if (existing) { this.recovery.clearTimers(existing) } diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 42e6ccc15f5..9610f2600d1 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -604,6 +604,8 @@ describePosix('daemon shell-ready launch config', () => { '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' + const primeWrapperLine = + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) @@ -625,6 +627,9 @@ describePosix('daemon shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + expect(zshrc).toContain(primeWrapperLine) + expect(zlogin).toContain(primeWrapperLine) + expect(bashRc).toContain(primeWrapperLine) }) // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 38a26be8b75..27bcf6f48cb 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -11,6 +11,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper' import { getFishShellReadyInitCommand, getZshEnvTemplate, @@ -122,6 +123,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent @@ -243,6 +245,7 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} + ${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi __orca_osc133_precmd() { @@ -306,6 +309,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 05cafc81785..02a0e28b9ad 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -55,6 +55,7 @@ const { clearAgentHookPaneStateMock, registerPaneKeyAliasMock, piBuildPtyEnvMock, + piBuildStatusOnlyPtyEnvMock, piClearPtyMock, isPwshAvailableMock, trackMock, @@ -91,6 +92,7 @@ const { clearAgentHookPaneStateMock: vi.fn(), registerPaneKeyAliasMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), + piBuildStatusOnlyPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn(), trackMock: vi.fn(), classifyErrorMock: vi.fn(), @@ -175,6 +177,7 @@ vi.mock('../agent-hooks/server', () => ({ vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: piBuildPtyEnvMock, + buildStatusOnlyPtyEnv: piBuildStatusOnlyPtyEnvMock, clearPty: piClearPtyMock } })) @@ -333,6 +336,7 @@ describe('registerPtyHandlers', () => { const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION const savedPrimeAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR const savedOrcaPrimeAgentSourceDir = process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + const savedOrcaPrimeAgentStatusExtension = process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL @@ -360,6 +364,7 @@ describe('registerPtyHandlers', () => { delete process.env.ORCA_OMP_STATUS_EXTENSION delete process.env.PRIME_AGENT_CODING_AGENT_DIR delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -382,6 +387,7 @@ describe('registerPtyHandlers', () => { clearAgentHookPaneStateMock.mockReset() registerPaneKeyAliasMock.mockReset() piBuildPtyEnvMock.mockReset() + piBuildStatusOnlyPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() trackMock.mockReset() @@ -481,6 +487,10 @@ describe('registerPtyHandlers', () => { } } ) + piBuildStatusOnlyPtyEnvMock.mockReturnValue({ + ORCA_PRIME_AGENT_STATUS_EXTENSION: + 'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts' + }) isPwshAvailableMock.mockReturnValue(false) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -586,6 +596,11 @@ describe('registerPtyHandlers', () => { } else { delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR } + if (savedOrcaPrimeAgentStatusExtension !== undefined) { + process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION = savedOrcaPrimeAgentStatusExtension + } else { + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION + } if (savedOrcaClaudeAgentStatusSettings === undefined) { delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS } else { @@ -4196,7 +4211,7 @@ describe('registerPtyHandlers', () => { }) }) - it('does not materialize Windows-host Prime extensions for a WSL launch', async () => { + it('bridges a host-managed Prime status extension into WSL without redirecting Prime home', async () => { await withWin32Platform(async () => { const env = await daemonSpawnAndGetEnv( { PRIME_AGENT_CODING_AGENT_DIR: 'C:\\Users\\test\\.prime\\agent' }, @@ -4207,11 +4222,30 @@ describe('registerPtyHandlers', () => { ) expect(piBuildPtyEnvMock).not.toHaveBeenCalled() + expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent') expect(env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toBe( + 'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts' + ) + expect(env.ORCA_WSL_HOOK_INSTANCE).toBe('port5678') expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBe('C:\\Users\\test\\.prime\\agent') }) }) + it('prepares the Prime bridge for a typed launch in a bare WSL shell', async () => { + await withWin32Platform(async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, { + shellOverride: 'wsl.exe' + }) + + expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent') + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toContain( + 'prime-agent-managed-status-extension' + ) + expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBeUndefined() + }) + }) + it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => { const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc' const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 219829c619e..dd48011baf0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1513,6 +1513,7 @@ function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKin } if (kind === 'prime-agent') { delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION return } delete baseEnv.ORCA_PI_CODING_AGENT_DIR @@ -1544,6 +1545,11 @@ function exposePiManagedExtensionEnv( } else { delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR } + if (managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION) { + baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION = managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } else { + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } return } delete baseEnv.ORCA_PI_CODING_AGENT_DIR @@ -1686,6 +1692,8 @@ export function buildPtyHostEnv( }) const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand + const shouldPreparePrimeStatus = + piAgentKind === 'prime-agent' || (opts.isWsl && !hasLaunchCommand) // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') const preexistingOmpAgentDir = @@ -1744,6 +1752,12 @@ export function buildPtyHostEnv( if (opts.isWsl === true) { // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. const distro = opts.wslDistro ?? null + const hookInstance = wslHookRelayManager.getInstanceKey() + if (hookInstance) { + baseEnv.ORCA_WSL_HOOK_INSTANCE = hookInstance + } else { + delete baseEnv.ORCA_WSL_HOOK_INSTANCE + } wslHookRelayManager.ensureForDistro(distro) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { @@ -1790,13 +1804,14 @@ export function buildPtyHostEnv( exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) } - if (piAgentKind === 'prime-agent' && !opts.isWsl) { - const primeEnv = piTitlebarExtensionService.buildPtyEnv( - id, - preexistingPrimeAgentDir, - 'prime-agent', - { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } - ) + if (shouldPreparePrimeStatus) { + // Why: the Windows host cannot safely resolve or write Prime's guest + // config root; WSL loads the host-managed status file explicitly. + const primeEnv = opts.isWsl + ? piTitlebarExtensionService.buildStatusOnlyPtyEnv('prime-agent') + : piTitlebarExtensionService.buildPtyEnv(id, preexistingPrimeAgentDir, 'prime-agent', { + materializeDefaultHome: explicitPiAgentKind === 'prime-agent' + }) Object.assign(baseEnv, primeEnv) exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) } @@ -1814,6 +1829,7 @@ export function buildPtyHostEnv( }) delete baseEnv.ORCA_OMP_STATUS_EXTENSION delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION } // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index ee051e938d1..b735cd60198 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -30,6 +30,7 @@ type Harness = { fsMock: { existsSync: ReturnType readFileSync: ReturnType + statSync: ReturnType } handlers: Record processEnv: Record @@ -62,6 +63,7 @@ function createHarness(args: { argv?: string[] existsSync?: (path: string) => boolean readFileSync?: (path: string, encoding: string) => string + statSync?: (path: string) => { mtimeMs: number; size: number; ino: number } fetchImpl?: (...params: Parameters) => Promise }): Harness { const fetchMock = vi.fn( @@ -86,6 +88,12 @@ function createHarness(args: { const fsMock = { existsSync: vi.fn(args.existsSync ?? (() => false)), + statSync: vi.fn( + args.statSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ), readFileSync: vi.fn( args.readFileSync ?? ((path: string) => { @@ -562,6 +570,31 @@ describe('getPiAgentStatusExtensionSource', () => { ) }) + it('uses current Windows coordinates when a same-token guest endpoint is stale', async () => { + const endpointPath = '/home/u/.orca-wsl/agent-hooks/instance-test/endpoint.env' + const harness = createHarness({ + kind: 'prime-agent', + env: { WSL_DISTRO_NAME: 'Ubuntu', ORCA_AGENT_HOOK_ENDPOINT: endpointPath }, + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + statSync: () => ({ mtimeMs: 1, size: 80, ino: 1 }), + readFileSync: (path) => { + if (path === endpointPath) { + return 'ORCA_AGENT_HOOK_PORT=9999\nORCA_AGENT_HOOK_TOKEN=token-1\n' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('stale guest relay') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:9999/hook/prime-agent') + await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(1)) + expect(harness.spawnMock.mock.calls[0]?.[1]).toContain('http://127.0.0.1:4321/hook/prime-agent') + }) + it('probes WSL evidence and the curl path once per process', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index ff2240531ce..6adfdf23fbc 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -13,6 +13,7 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source' import { getPiAgentStatusRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source' +import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-source' export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' @@ -228,92 +229,13 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' // Why: status reporting must never fail the pi run just because Orca', ' // is unavailable or the loopback request failed (e.g. Orca restart).', ' if (!isWslRuntime()) return', - ' postViaWindowsCurl(url, coords, body)', + ' postViaWindowsCurl(body, ompRuntime)', ' } finally {', ' if (timeout) clearTimeout(timeout)', ' }', '}', '', - '// Why: WSL-ness and curl.exe presence cannot change within a process', - '// lifetime; re-probing /proc and /mnt/c on every failed event would add', - '// filesystem work to the per-event path.', - 'let cachedIsWslRuntime: boolean | null = null', - 'let cachedWindowsCurlPath: string | null | undefined', - '', - 'function isWslRuntime(): boolean {', - ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', - ' cachedIsWslRuntime = detectWslRuntime()', - ' return cachedIsWslRuntime', - '}', - '', - 'function detectWslRuntime(): boolean {', - ' if (process.env.WSL_DISTRO_NAME) return true', - ' try {', - " const fs = require('fs')", - " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", - ' try {', - " const contents = String(fs.readFileSync(path, 'utf8'))", - ' if (/microsoft|wsl/i.test(contents)) return true', - ' } catch {', - ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', - ' }', - ' }', - ' } catch {', - ' return false', - ' }', - ' return false', - '}', - '', - 'function resolveWindowsCurlPath(): string | null {', - ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', - ' try {', - " const fs = require('fs')", - " const curlPath = '/mnt/c/Windows/System32/curl.exe'", - ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', - ' } catch {', - ' cachedWindowsCurlPath = null', - ' }', - ' return cachedWindowsCurlPath', - '}', - '', - '// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot', - '// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the', - '// listener Orca binds. Fire-and-forget: blocking on the spawn would stall', - '// the pi event loop (and the TUI) on every hook event.', - 'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {', - ' const curlPath = resolveWindowsCurlPath()', - ' if (!curlPath) return', - ' try {', - " const { spawn } = require('child_process')", - ' const child = spawn(', - ' curlPath,', - ' [', - " '-sS',", - ' // Why: the spawn is detached from the event loop, so these bounds', - ' // size a background process, not TUI latency. WSL->Win32 interop', - ' // connects can exceed 0.5s on loaded machines (observed 3/3 drops', - ' // to a healthy listener); size for delivery, not snappiness.', - " '--connect-timeout', '3',", - " '--max-time', '10',", - " '--noproxy', '127.0.0.1',", - " '-o', 'NUL',", - " '-X', 'POST',", - " '-H', 'Content-Type: application/json',", - " '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,", - " '--data-binary', '@-',", - ' url', - ' ],', - " { stdio: ['pipe', 'ignore', 'ignore'] }", - ' )', - " child.on('error', () => {})", - " child.stdin.on('error', () => {})", - ' child.stdin.end(body)', - ' } catch {', - ' // Why: the bridge is best-effort; a failed spawn must not surface', - ' // inside the pi TUI.', - ' }', - '}', - '', + ...getPiAgentStatusWslCurlSourceLines(), ...getPiAgentStatusHandlerSourceLines(kind) ].join('\n') } diff --git a/src/main/pi/agent-status-wsl-curl-source.ts b/src/main/pi/agent-status-wsl-curl-source.ts new file mode 100644 index 00000000000..558e25a49db --- /dev/null +++ b/src/main/pi/agent-status-wsl-curl-source.ts @@ -0,0 +1,81 @@ +export function getPiAgentStatusWslCurlSourceLines(): string[] { + return [ + '// Why: WSL-ness and curl.exe presence cannot change within a process', + '// lifetime; re-probing /proc and /mnt/c on every failed event would add', + '// filesystem work to the per-event path.', + 'let cachedIsWslRuntime: boolean | null = null', + 'let cachedWindowsCurlPath: string | null | undefined', + '', + 'function isWslRuntime(): boolean {', + ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', + ' cachedIsWslRuntime = detectWslRuntime()', + ' return cachedIsWslRuntime', + '}', + '', + 'function detectWslRuntime(): boolean {', + ' if (process.env.WSL_DISTRO_NAME) return true', + ' try {', + " const fs = require('fs')", + " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", + ' try {', + " const contents = String(fs.readFileSync(path, 'utf8'))", + ' if (/microsoft|wsl/i.test(contents)) return true', + ' } catch {', + ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', + ' }', + ' }', + ' } catch {', + ' return false', + ' }', + ' return false', + '}', + '', + 'function resolveWindowsCurlPath(): string | null {', + ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', + ' try {', + " const fs = require('fs')", + " const curlPath = '/mnt/c/Windows/System32/curl.exe'", + ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', + ' } catch {', + ' cachedWindowsCurlPath = null', + ' }', + ' return cachedWindowsCurlPath', + '}', + '', + '// Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.', + 'function postViaWindowsCurl(body: string, ompRuntime: boolean): void {', + ' const curlPath = resolveWindowsCurlPath()', + ' const windowsPort = process.env.ORCA_AGENT_HOOK_PORT', + ' const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN', + ' if (!curlPath || !windowsPort || !windowsToken) return', + ' // Why: a stale guest endpoint must fall back to current host coordinates.', + ' const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`', + ' try {', + " const { spawn } = require('child_process')", + ' const child = spawn(', + ' curlPath,', + ' [', + " '-sS',", + ' // Why: detached delivery may take seconds under loaded WSL interop.', + " '--connect-timeout', '3',", + " '--max-time', '10',", + " '--noproxy', '127.0.0.1',", + " '-o', 'NUL',", + " '-X', 'POST',", + " '-H', 'Content-Type: application/json',", + " '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,", + " '--data-binary', '@-',", + ' windowsUrl', + ' ],', + " { stdio: ['pipe', 'ignore', 'ignore'] }", + ' )', + " child.on('error', () => {})", + " child.stdin.on('error', () => {})", + ' child.stdin.end(body)', + ' } catch {', + ' // Why: status delivery must not surface inside the agent TUI.', + ' }', + '}', + '' + ] +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index e197a4a0c3a..485ea71cafa 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -94,6 +94,10 @@ describe('PiTitlebarExtensionService', () => { rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-managed-status-extension'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'prime-agent-managed-status-extension'), { + recursive: true, + force: true + }) }) function expectPiHomeIntact(): void { @@ -181,6 +185,21 @@ describe('PiTitlebarExtensionService', () => { expectPiHomeIntact() }) + it('writes a Prime status-only extension outside the guest-owned config dir', () => { + const svc = new PiTitlebarExtensionService() + const env = svc.buildStatusOnlyPtyEnv('prime-agent') + const statusPath = join( + userDataDir, + 'prime-agent-managed-status-extension', + 'orca-agent-status.ts' + ) + + expect(env).toEqual({ ORCA_PRIME_AGENT_STATUS_EXTENSION: statusPath }) + expect(readFileSync(statusPath, 'utf8')).toContain('/hook/prime-agent') + expect(readFileSync(statusPath, 'utf8')).not.toContain("return '/hook/omp'") + expectPiHomeIntact() + }) + it('uses the same source dir for multiple PTYs with the same Pi dir', () => { const svc = new PiTitlebarExtensionService() const firstEnv = svc.buildPtyEnv('pty-shared-1', piHome, 'pi') diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 5e728fa0274..59c0f44797f 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -27,7 +27,10 @@ export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const PI_AGENT_SUBDIR = 'agent' const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' -const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' +const MANAGED_STATUS_EXTENSION_DIR: Record<'omp' | 'prime-agent', string> = { + omp: 'omp-managed-status-extension', + 'prime-agent': 'prime-agent-managed-status-extension' +} type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed' @@ -121,8 +124,11 @@ export class PiTitlebarExtensionService { } } - private writeOmpFallbackStatusExtension(source: string): string | undefined { - const fallbackDir = join(app.getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR) + private writeFallbackStatusExtension( + source: string, + kind: keyof typeof MANAGED_STATUS_EXTENSION_DIR + ): string | undefined { + const fallbackDir = join(app.getPath('userData'), MANAGED_STATUS_EXTENSION_DIR[kind]) try { mkdirSync(fallbackDir, { recursive: true }) } catch { @@ -165,7 +171,7 @@ export class PiTitlebarExtensionService { statusResult === 'written' ? statusExtensionPath : kind === 'omp' - ? this.writeOmpFallbackStatusExtension(statusSource) + ? this.writeFallbackStatusExtension(statusSource, 'omp') : undefined } } @@ -195,7 +201,7 @@ export class PiTitlebarExtensionService { if (!existsSync(sourceAgentDir) && !materializeDefaultHome) { if (kind === 'omp') { const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) - const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource) + const statusExtensionPath = this.writeFallbackStatusExtension(statusSource, 'omp') return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {} } return {} @@ -220,6 +226,12 @@ export class PiTitlebarExtensionService { return env } + buildStatusOnlyPtyEnv(kind: 'prime-agent'): Record { + const source = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) + const statusExtensionPath = this.writeFallbackStatusExtension(source, kind) + return statusExtensionPath ? { ORCA_PRIME_AGENT_STATUS_EXTENSION: statusExtensionPath } : {} + } + clearPty(ptyId: string): void { // Why: PTY teardown doesn't know which kind was launched (the daemon // exit path discards the launch command). Sweep both old PTY-scoped diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 1cb10ad7f14..d9034a5cc24 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -526,6 +526,8 @@ describePosix('local PTY shell-ready launch config', () => { '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' + const primeWrapperLine = + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) @@ -547,6 +549,9 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + expect(zshrc).toContain(primeWrapperLine) + expect(zlogin).toContain(primeWrapperLine) + expect(bashRc).toContain(primeWrapperLine) }) // Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited. diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 18052dfa670..63885e23579 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -15,6 +15,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { getFishShellReadyInitCommand, @@ -129,6 +130,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent @@ -245,6 +247,7 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} + ${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi @@ -305,6 +308,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 39cb81ee9b5..53a83e6638d 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -280,13 +280,16 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) - // Why: the point of materializing wrappers for WSL is that a typed `omp` - // picks up Orca's status extension; pin that shim end to end. + // Why: typed agents need the host-managed status extension in WSL. const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') for (const wrapperFile of [bashRcfile, zshLogin]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + expect(wrapperFile).toContain( + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' + ) + expect(wrapperFile).toContain('prime-agent() { __orca_prime_agent "$@"; }') } }) diff --git a/src/main/pty/prime-agent-shell-wrapper.test.ts b/src/main/pty/prime-agent-shell-wrapper.test.ts new file mode 100644 index 00000000000..1ad1fe774cb --- /dev/null +++ b/src/main/pty/prime-agent-shell-wrapper.test.ts @@ -0,0 +1,244 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getPosixPrimeAgentShellWrapper } from './prime-agent-shell-wrapper' + +const describePosix = process.platform === 'win32' ? describe.skip : describe +const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 +const itWithBash = hasBash ? it : it.skip +const tempDirs: string[] = [] + +function makeFixture(): { + root: string + extensionPath: string + capturePath: string + endpointPath: string + primeHome: string + rcfile: string +} { + const root = mkdtempSync(join(tmpdir(), 'orca-prime-wrapper-')) + tempDirs.push(root) + const binDir = join(root, 'bin') + const primeHome = join(root, 'guest-prime-home') + const extensionPath = join(root, 'orca-agent-status.ts') + const capturePath = join(root, 'capture') + const endpointPath = join( + root, + '.orca-wsl', + 'agent-hooks', + 'instance-testinstance', + 'endpoint.env' + ) + const rcfile = join(root, 'rcfile') + mkdirSync(binDir) + mkdirSync(primeHome) + mkdirSync(dirname(endpointPath), { recursive: true }) + writeFileSync(extensionPath, 'export default {}') + writeFileSync(endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=current-token\n') + writeFileSync(rcfile, getPosixPrimeAgentShellWrapper()) + writeFileSync( + join(binDir, 'prime-agent'), + `#!/bin/sh +{ + printf 'HOME=%s\n' "$PRIME_AGENT_CODING_AGENT_DIR" + printf 'ENDPOINT=%s\n' "$ORCA_AGENT_HOOK_ENDPOINT" + i=0 + for arg in "$@"; do + i=$((i + 1)) + printf 'ARG%s=%s\n' "$i" "$arg" + done +} > "$ORCA_CAPTURE_FILE" +if [ "\${1:-}" = "config" ]; then + printf 'preserved\n' > "$PRIME_AGENT_CODING_AGENT_DIR/config.yml" +fi +`, + { mode: 0o755 } + ) + chmodSync(join(binDir, 'prime-agent'), 0o755) + return { root, extensionPath, capturePath, endpointPath, primeHome, rcfile } +} + +function runPrime( + fixture: ReturnType, + command: string, + extensionPath = fixture.extensionPath +): string { + const result = spawnSync('bash', ['--noprofile', '--rcfile', fixture.rcfile, '-ic', command], { + cwd: fixture.root, + env: { + ...process.env, + HOME: fixture.root, + PATH: `${join(fixture.root, 'bin')}:${process.env.PATH ?? ''}`, + PRIME_AGENT_CODING_AGENT_DIR: fixture.primeHome, + ORCA_PRIME_AGENT_STATUS_EXTENSION: extensionPath, + ORCA_WSL_HOOK_INSTANCE: 'testinstance', + ORCA_AGENT_HOOK_ENDPOINT: '/mnt/c/current/endpoint.cmd', + ORCA_AGENT_HOOK_PORT: '4567', + ORCA_AGENT_HOOK_TOKEN: 'current-token', + ORCA_CAPTURE_FILE: fixture.capturePath + }, + encoding: 'utf8' + }) + expect(result.status, result.stderr).toBe(0) + return readFileSync(fixture.capturePath, 'utf8') +} + +describePosix('Prime Agent shell wrapper', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + itWithBash('injects the managed extension into an interactive launch', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`HOME=${fixture.primeHome}`) + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + expect(capture).toContain(`ARG2=${fixture.extensionPath}`) + expect(capture).toContain('ARG3=ask') + }) + + itWithBash('preserves config subcommands and the guest Prime home', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent config') + + expect(capture).toContain(`HOME=${fixture.primeHome}`) + expect(capture).toContain('ARG1=config') + expect(capture).not.toContain('ARG1=--extension') + expect(readFileSync(join(fixture.primeHome, 'config.yml'), 'utf8')).toBe('preserved\n') + }) + + itWithBash.each([ + 'agents', + 'doctor', + 'help', + 'list', + 'model', + 'package', + 'rename', + 'schedule', + 'send', + 'session', + 'shutdown', + 'status', + 'stop', + 'update' + ])('preserves the %s subcommand argv', (subcommand) => { + const fixture = makeFixture() + const capture = runPrime(fixture, `prime-agent ${subcommand}`) + + expect(capture).toContain(`ARG1=${subcommand}`) + expect(capture).not.toContain('ARG1=--extension') + }) + + itWithBash.each(['stop', 'rename'])( + 'preserves --daemon-socket before the %s management command', + (subcommand) => { + const fixture = makeFixture() + const capture = runPrime( + fixture, + `prime-agent --daemon-socket /tmp/prime.sock ${subcommand} agent-123` + ) + + expect(capture).toContain('ARG1=--daemon-socket') + expect(capture).toContain('ARG2=/tmp/prime.sock') + expect(capture).toContain(`ARG3=${subcommand}`) + expect(capture).not.toContain(fixture.extensionPath) + } + ) + + itWithBash('injects the extension after the attach agent operand', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent attach agent-123 --follow') + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=agent-123') + expect(capture).toContain('ARG3=--extension') + expect(capture).toContain(`ARG4=${fixture.extensionPath}`) + expect(capture).toContain('ARG5=--follow') + }) + + itWithBash('preserves attach help without inventing an agent operand', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent attach --help') + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=--help') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('does not override an explicit user extension', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent --extension /tmp/user-extension.ts ask') + + expect(capture).toContain('ARG1=--extension') + expect(capture).toContain('ARG2=/tmp/user-extension.ts') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('does not add a second extension to attach', () => { + const fixture = makeFixture() + const capture = runPrime( + fixture, + 'prime-agent attach agent-123 --extension /tmp/user-extension.ts' + ) + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=agent-123') + expect(capture).toContain('ARG3=--extension') + expect(capture).toContain('ARG4=/tmp/user-extension.ts') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('degrades to the original argv when the managed extension is unavailable', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent ask', join(fixture.root, 'missing.ts')) + + expect(capture).toContain('ARG1=ask') + expect(capture).not.toContain('ARG1=--extension') + }) + + itWithBash('keeps the stable guest endpoint path while its relay refreshes stale data', () => { + const fixture = makeFixture() + writeFileSync(fixture.endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=stale\n') + + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + }) + + itWithBash('uses the guest endpoint path when the relay binds a fallback port', () => { + const fixture = makeFixture() + writeFileSync( + fixture.endpointPath, + 'ORCA_AGENT_HOOK_PORT=9876\nORCA_AGENT_HOOK_TOKEN=current-token\n' + ) + + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + }) + + itWithBash('does not define wrapper helpers when the bridge env is absent', () => { + const fixture = makeFixture() + const result = spawnSync( + 'bash', + ['--noprofile', '--rcfile', fixture.rcfile, '-ic', 'type -t __orca_prime_agent'], + { + cwd: fixture.root, + env: { ...process.env, ORCA_PRIME_AGENT_STATUS_EXTENSION: '' }, + encoding: 'utf8' + } + ) + + expect(result.status).not.toBe(0) + expect(result.stdout.trim()).toBe('') + }) +}) diff --git a/src/main/pty/prime-agent-shell-wrapper.ts b/src/main/pty/prime-agent-shell-wrapper.ts new file mode 100644 index 00000000000..97e723c26f1 --- /dev/null +++ b/src/main/pty/prime-agent-shell-wrapper.ts @@ -0,0 +1,74 @@ +import { wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract' + +const PRIME_AGENT_SUBCOMMANDS = [ + 'agents', + 'config', + 'doctor', + 'help', + 'list', + 'model', + 'package', + 'rename', + 'schedule', + 'send', + 'session', + 'shutdown', + 'status', + 'stop', + 'update' +] as const + +export function getPosixPrimeAgentShellWrapper(): string { + const subcommands = PRIME_AGENT_SUBCOMMANDS.join('|') + const guestEndpointPath = wslHookRelayEndpointFilePath('${HOME%/}', '${ORCA_WSL_HOOK_INSTANCE}') + return `# Why: WSL cannot install into Prime's guest-owned config root from the +# Windows host, so pass Orca's status extension only to interactive launches. +if [[ -n "\${ORCA_PRIME_AGENT_STATUS_EXTENSION:-}" ]]; then + __orca_prime_agent_should_skip_extension() { + if [[ "\${1:-}" == "--daemon-socket" && ( "\${3:-}" == "stop" || "\${3:-}" == "rename" ) ]]; then + return 0 + fi + if [[ "\${1:-}" == --daemon-socket=* && ( "\${2:-}" == "stop" || "\${2:-}" == "rename" ) ]]; then + return 0 + fi + case "\${1:-}" in + help|--help|-h|--version|-v|--extension|--extension=*) return 0 ;; + ${subcommands}) return 0 ;; + esac + return 1 + } + __orca_prime_agent_has_explicit_extension() { + local __orca_arg + for __orca_arg in "$@"; do + case "$__orca_arg" in + --extension|--extension=*) return 0 ;; + esac + done + return 1 + } + __orca_prime_agent() { + if [[ "\${1:-}" == "attach" && ( -z "\${2:-}" || "\${2:-}" == -* ) ]]; then + command prime-agent "$@" + return + fi + if ! __orca_prime_agent_should_skip_extension "$@" && ! __orca_prime_agent_has_explicit_extension "$@" && [[ -f "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" ]]; then + local __orca_guest_endpoint="${guestEndpointPath}" + if [[ -n "\${HOME:-}" && -n "\${ORCA_WSL_HOOK_INSTANCE:-}" ]]; then + local ORCA_AGENT_HOOK_ENDPOINT="$__orca_guest_endpoint" + export ORCA_AGENT_HOOK_ENDPOINT + fi + if [[ "\${1:-}" == "attach" && -n "\${2:-}" && "\${2:-}" != -* ]]; then + local __orca_attach_agent="$2" + shift 2 + command prime-agent attach "$__orca_attach_agent" --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@" + else + command prime-agent --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@" + fi + else + command prime-agent "$@" + fi + } + prime-agent() { __orca_prime_agent "$@"; } +fi +` +} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 15c6b43cf44..c27655ba7e4 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -48,6 +48,8 @@ describe('addOrcaWslInteropEnv', () => { ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', + ORCA_PRIME_AGENT_STATUS_EXTENSION: + 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', @@ -56,6 +58,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_AGENT_HOOK_ENV: 'dev', ORCA_AGENT_HOOK_VERSION: '1', + ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' @@ -67,6 +70,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') + expect(env.WSLENV).toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u') @@ -75,6 +79,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') + expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 40f3b957249..5e90eaa6b4a 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -96,6 +96,7 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_WSL_HOOK_INSTANCE/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', 'ORCA_OMP_STATUS_EXTENSION/p', + 'ORCA_PRIME_AGENT_STATUS_EXTENSION/p', ...worktreeSetupWslenvEntries(env) ] applyWslenvPassthrough(env, passthroughEntries)