From 3b055c869f7d52ce2b01297582705dd40d02b0fa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:17:54 -0700 Subject: [PATCH] fix(wsl): await Pi and OMP guest relay materialization (#21721) * fix(wsl): await Pi and OMP guest relay materialization * fix(wsl): materialize Pi extension before guest launch * fix(wsl): keep relay state under lint limit * fix(wsl): preserve guest agent readiness across launches * test(wsl): expect guest status path translation * ci: rerun PR checks after rebase * ci: retrigger PR checks * ci: run final PR verification --- .../agent-hooks/wsl-guest-plugin-install.ts | 22 ++- src/main/agent-hooks/wsl-hook-relay-deps.ts | 5 +- .../wsl-hook-relay-guest-install.ts | 55 +++++- .../wsl-hook-relay-manager.test.ts | 91 ++++++++- .../agent-hooks/wsl-hook-relay-manager.ts | 183 +++++++----------- .../wsl-hook-relay-state-machine.ts | 72 +++++++ src/main/agent-hooks/wsl-hook-relay-state.ts | 24 +++ .../agent-hooks/wsl-pi-omp-guest-readiness.ts | 49 +++++ .../pty-serializer-settlement-mapping.test.ts | 2 +- .../ipc/pty-spawn-env-terminal-basics.test.ts | 4 +- src/main/ipc/pty-write-ipc-validation.test.ts | 2 +- src/main/ipc/pty/host-env/assembly.ts | 21 +- src/main/ipc/pty/provider/local-configure.ts | 8 + src/main/ipc/pty/runtime/spawn-preflight.ts | 8 + src/main/pty/wsl-orca-env.ts | 3 +- src/relay/wsl-install-plugins-handler.test.ts | 14 ++ src/relay/wsl-install-plugins-handler.ts | 25 ++- 17 files changed, 450 insertions(+), 138 deletions(-) create mode 100644 src/main/agent-hooks/wsl-hook-relay-state-machine.ts create mode 100644 src/main/agent-hooks/wsl-hook-relay-state.ts create mode 100644 src/main/agent-hooks/wsl-pi-omp-guest-readiness.ts diff --git a/src/main/agent-hooks/wsl-guest-plugin-install.ts b/src/main/agent-hooks/wsl-guest-plugin-install.ts index a8a84b50c17..80b98bed6f4 100644 --- a/src/main/agent-hooks/wsl-guest-plugin-install.ts +++ b/src/main/agent-hooks/wsl-guest-plugin-install.ts @@ -16,29 +16,39 @@ type GuestPluginInstallDeps = { * with `unavailable` (no handler / teardown): only `none` means the previously * recorded dir is now unusable and must stop being advertised to PTYs. */ export type GuestOverlayResult = - | { kind: 'dir'; dir?: string; dir2?: string } + | { kind: 'dir'; dir?: string; dir2?: string; piDir?: string; ompDir?: string } | { kind: 'none' } | { kind: 'unavailable' } export async function requestGuestOpenCodeOverlayDir( mux: SshChannelMultiplexer, deps: GuestPluginInstallDeps, - distro: string + distro: string, + launchKind?: 'pi' | 'omp' ): Promise { try { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. - const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, deps.pluginSources())) as { - overlayDirs?: { opencode?: unknown; opencode2?: unknown } + const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { + ...deps.pluginSources(), + ...(launchKind ? { launchKind } : {}) + })) as { + overlayDirs?: { opencode?: unknown; opencode2?: unknown; pi?: unknown; omp?: unknown } } const dir = res?.overlayDirs?.opencode const dir2 = res?.overlayDirs?.opencode2 + const piDir = res?.overlayDirs?.pi + const ompDir = res?.overlayDirs?.omp const opencodeDir = typeof dir === 'string' && dir.length > 0 ? dir : undefined const opencode2Dir = typeof dir2 === 'string' && dir2.length > 0 ? dir2 : undefined - return opencodeDir || opencode2Dir + const guestPiDir = typeof piDir === 'string' && piDir.length > 0 ? piDir : undefined + const guestOmpDir = typeof ompDir === 'string' && ompDir.length > 0 ? ompDir : undefined + return opencodeDir || opencode2Dir || guestPiDir || guestOmpDir ? { kind: 'dir', ...(opencodeDir ? { dir: opencodeDir } : {}), - ...(opencode2Dir ? { dir2: opencode2Dir } : {}) + ...(opencode2Dir ? { dir2: opencode2Dir } : {}), + ...(guestPiDir ? { piDir: guestPiDir } : {}), + ...(guestOmpDir ? { ompDir: guestOmpDir } : {}) } : { kind: 'none' } } catch (err) { diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 30805b02cf1..77b2390747c 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -10,6 +10,7 @@ import { agentHookServer } from './server' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { getOpenCode2PluginSource, getOpenCodePluginSource } from '../opencode/hook-service' +import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source' import { codexHookService } from '../codex/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { PluginSources } from '../../relay/plugin-overlay' @@ -121,7 +122,9 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { // Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later. pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource(), - opencode2PluginSource: getOpenCode2PluginSource() + opencode2PluginSource: getOpenCode2PluginSource(), + piExtensionSource: getPiAgentStatusExtensionSource('pi'), + ompExtensionSource: getPiAgentStatusExtensionSource('omp') }), warn: (message) => console.warn(message), transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index e985664e403..103a5e9909b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -27,7 +27,20 @@ type GuestInstallState = { codexHomePath?: string opencodeOverlayDir?: string opencode2OverlayDir?: string + piAgentDir?: string + ompStatusExtension?: string lastInstallAt?: number + launchKinds?: Set<'pi' | 'omp'> + installation?: Promise +} + +function* requestedKinds(state: GuestInstallState): Generator<'pi' | 'omp' | undefined> { + if (!state.launchKinds?.size) { + yield undefined + } + if (state.launchKinds) { + yield* state.launchKinds + } } export async function runWslRelayGuestInstall( @@ -35,6 +48,23 @@ export async function runWslRelayGuestInstall( state: GuestInstallState, mux: SshChannelMultiplexer, guestHome: string +): Promise { + if (state.installation) { + return state.installation + } + state.installation = installGuestHooksAndPlugins(deps, state, mux, guestHome) + try { + await state.installation + } finally { + state.installation = undefined + } +} + +async function installGuestHooksAndPlugins( + deps: GuestInstallDeps, + state: GuestInstallState, + mux: SshChannelMultiplexer, + guestHome: string ): Promise { state.lastInstallAt = Date.now() await installWslGuestHooks({ @@ -49,12 +79,21 @@ export async function runWslRelayGuestInstall( }) // Why: ship OpenCode's status plugin and record the guest overlay dir the // PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown. - const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro) - if (state.mux === mux && overlay.kind !== 'unavailable') { - // Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir - // present but plugin-less, and advertising it would hide the user's own config. - state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined - state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined + const kinds = requestedKinds(state) + for (const kind of kinds) { + const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro, kind) + if (state.mux !== mux) { + return + } + if (overlay.kind !== 'unavailable') { + state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined + state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined + if (kind === 'pi') { + state.piAgentDir = overlay.kind === 'dir' ? overlay.piDir : undefined + } else if (kind === 'omp') { + state.ompStatusExtension = overlay.kind === 'dir' ? overlay.ompDir : undefined + } + } } } @@ -63,6 +102,10 @@ export async function maybeRerunWslRelayGuestInstall( deps: GuestInstallDeps, state: GuestInstallState ): Promise { + if (state.installation) { + await state.installation + return + } const mux = state.mux const guestHome = state.guestHome if ( 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 d2f528cc546..c2f86ea8008 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -16,6 +16,7 @@ import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers' import { WslHookRelayManager } from './wsl-hook-relay-manager' +import { awaitExplicitPiOmpGuestReadiness } from './wsl-pi-omp-guest-readiness' import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, @@ -202,7 +203,12 @@ describe('WslHookRelayManager', () => { if (registerInstallPlugins) { harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({ installed: { opencode: true, opencode2: true, pi: false, omp: false }, - overlayDirs: { opencode: opencodeOverlayDir, opencode2: opencode2OverlayDir } + overlayDirs: { + opencode: opencodeOverlayDir, + opencode2: opencode2OverlayDir, + pi: `${home}/.pi/agent`, + omp: `${home}/.omp/agent/extensions/orca-agent-status.ts` + } })) } return harness.transport @@ -287,6 +293,89 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('waits for guest materialization when an explicit Pi or OMP launch needs it', async () => { + const { manager } = createManager({}) + await expect( + awaitExplicitPiOmpGuestReadiness({ + isWsl: true, + distro: 'Ubuntu', + codexHomePath: codexHome, + launchAgent: 'pi', + manager + }) + ).resolves.toBe(true) + expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir) + manager.disposeAll() + }) + + it('does not treat the endpoint as ready before guest install completes', async () => { + let releaseInstall!: () => void + const installGate = new Promise((resolve) => { + releaseInstall = resolve + }) + const { manager } = createManager({ + installHooks: vi.fn(async () => installGate.then(() => [])) + }) + const readiness = awaitExplicitPiOmpGuestReadiness({ + isWsl: true, + distro: 'Ubuntu', + launchAgent: 'pi', + timeoutMs: 50, + manager + }) + await vi.waitFor(() => expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()) + releaseInstall() + await expect(readiness).resolves.toBe(true) + manager.disposeAll() + }) + + it('reports relay startup failure without blocking the explicit launch forever', async () => { + const { manager } = createManager({ + waitForSentinel: vi.fn(async () => { + throw startupError(17, 'guest unavailable') + }) + }) + await expect( + awaitExplicitPiOmpGuestReadiness({ + isWsl: true, + distro: 'Ubuntu', + launchAgent: 'omp', + manager, + timeoutMs: 50 + }) + ).resolves.toBe(false) + manager.disposeAll() + }) + + it('times out a relay that never reaches guest materialization', async () => { + const { manager } = createManager({ + waitForSentinel: vi.fn(() => new Promise(() => {})) + }) + await expect( + awaitExplicitPiOmpGuestReadiness({ + isWsl: true, + distro: 'Ubuntu', + launchAgent: 'pi', + timeoutMs: 5, + manager + }) + ).resolves.toBe(false) + manager.disposeAll() + }) + + it('does not wait or start a relay for a bare shell', async () => { + const { manager } = createManager({}) + await expect( + awaitExplicitPiOmpGuestReadiness({ + isWsl: true, + distro: 'Ubuntu', + launchCommand: 'bash', + manager + }) + ).resolves.toBe(true) + expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull() + }) + it('forwards the WSL guest Claude version to the shared remote installer', async () => { const waitForSentinel = vi.fn(async () => guestTransport({ detectedAgents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' }) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 14941489aa4..2a2bdd4bba6 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -1,5 +1,4 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' - import { runWslRelayGuestInstall, maybeRerunWslRelayGuestInstall @@ -9,7 +8,6 @@ import { defaultWslHookRelayDeps, isWslHookRelayAllowed, FAILURE_COOLDOWN_BASE_MS, - FAILURE_COOLDOWN_MAX_MS, NO_NODE_COOLDOWN_MS, REINSTALL_ONE_SHOT_DELAY_MS, RUNNING_TEARDOWN_COOLDOWN_MS, @@ -30,37 +28,20 @@ import { recordManagedWslCodexHome, wslRuntimeHomePathsEqual } from '../codex/managed-wsl-codex-home-registry' -import { resolveWslHookDefaultDistro } from './wsl-hook-default-distro' -import { resumeStoppedWslHookRelays } from './wsl-hook-relay-resume' - -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 - codexHomePath?: string - guestEndpointFilePath?: string - opencodeOverlayDir?: string - opencode2OverlayDir?: string - failures: number - cooldownUntil: number - connectedAt?: number - restartTimer?: ReturnType - reinstallTimer?: ReturnType - lastInstallAt?: number -} - +import { + markWslRelayFailed, + resolveWslDefaultDistro, + resumeWslStoppedRelays +} from './wsl-hook-relay-state-machine' +import type { WslRelayDistroState } from './wsl-hook-relay-state' export class WslHookRelayManager { private deps: WslHookRelayManagerDeps private recovery: WslRelayRecovery - private states = new Map() + private states = new Map() private stoppedByHooksOff = new Map() private defaultDistro: string | null = null private disposed = false private warnedBundleMissing = false - constructor(deps: Partial = {}) { this.deps = { ...defaultWslHookRelayDeps, ...deps } this.recovery = new WslRelayRecovery({ @@ -70,8 +51,6 @@ export class WslHookRelayManager { isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state, restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath), 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 = wslHookRelayStateKey(state.distro) if (this.states.get(key) === state) { this.states.delete(key) @@ -79,31 +58,32 @@ export class WslHookRelayManager { } }) } - setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void { this.deps.managedHookSettings = resolve } - /** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */ - ensureForDistro(distro: string | null, codexHomePath?: string | null): void { + async ensureForDistro( + distro: string | null, + codexHomePath?: string | null, + launchKind?: 'pi' | 'omp' + ): Promise { if (this.disposed || !isWslHookRelayAllowed(this.deps)) { return } - void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => { + await this.ensureInternal(distro, codexHomePath ?? undefined, launchKind).catch((err) => { const detail = err instanceof Error ? err.message : String(err) this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) }) } - - private stateFor(distro: string | null): DistroState | undefined { - // Empty key never matches a real (non-empty) distro state. + private stateFor(distro: string | null): WslRelayDistroState | undefined { return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? '')) } - + /** Guest endpoint path once install completes. */ getGuestEndpointFilePath(distro: string | null): string | null { - return this.stateFor(distro)?.guestEndpointFilePath ?? null + return this.stateFor(distro)?.connectedAt + ? (this.stateFor(distro)?.guestEndpointFilePath ?? null) + : null } - getOpenCodeOverlayDir( distro: string | null, agent: 'opencode' | 'opencode2' = 'opencode' @@ -113,7 +93,10 @@ export class WslHookRelayManager { ? (state?.opencode2OverlayDir ?? null) : (state?.opencodeOverlayDir ?? null) } - + getGuestAgentPath(distro: string | null, kind: 'pi' | 'omp'): string | null { + const state = this.stateFor(distro) + return kind === 'pi' ? (state?.piAgentDir ?? null) : (state?.ompStatusExtension ?? 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 { @@ -128,20 +111,17 @@ export class WslHookRelayManager { } this.states.clear() } - /** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut * down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */ resumeStoppedRelays(): void { - resumeStoppedWslHookRelays( - this.stoppedByHooksOff, - this.deps.isDistroRunning, - (distro, codexHomePath) => this.ensureForDistro(distro, codexHomePath) + resumeWslStoppedRelays(this.stoppedByHooksOff, this.deps.isDistroRunning, (distro, home) => + this.ensureForDistro(distro, home) ) } - private async ensureInternal( requestedDistro: string | null, - requestedCodexHomePath?: string + requestedCodexHomePath?: string, + launchKind?: 'pi' | 'omp' ): Promise { const distro = requestedDistro ?? (await this.resolveDefaultDistro()) if (!distro || this.disposed) { @@ -153,6 +133,10 @@ export class WslHookRelayManager { recordManagedWslCodexHome(distro, requestedCodexHomePath) } if (existing) { + if (launchKind && !existing.launchKinds.has(launchKind)) { + existing.launchKinds.add(launchKind) + existing.lastInstallAt = 0 + } if ( requestedCodexHomePath && !wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath) @@ -161,10 +145,11 @@ export class WslHookRelayManager { existing.lastInstallAt = 0 } if (existing.phase === 'running') { - void maybeRerunWslRelayGuestInstall(this.deps, existing) + await maybeRerunWslRelayGuestInstall(this.deps, existing) return } if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) { + await existing.startup return } } @@ -181,56 +166,47 @@ export class WslHookRelayManager { } 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 = { + const state: WslRelayDistroState = { distro, phase: 'starting', failures: existing?.failures ?? 0, - // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay - // crash — dropping it would blank status on panes spawned mid-relaunch. opencodeOverlayDir: existing?.opencodeOverlayDir, opencode2OverlayDir: existing?.opencode2OverlayDir, + piAgentDir: existing?.piAgentDir, + ompStatusExtension: existing?.ompStatusExtension, + launchKinds: new Set(existing?.launchKinds ?? (launchKind ? [launchKind] : [])), codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath, 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, - // Why the identity half: a hooks-off teardown drops this state and kills its child, but - // that kill reads as a startup failure and the retry loop would respawn an untracked relay. - isDisposed: () => this.disposed || this.states.get(key) !== state, - 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.startup = launchWslRelayWithInstall({ + distro: state.distro, + env, + bundleJsPath: bundle.jsPath, + version: bundle.version, + io: this.deps, + isDisposed: () => this.disposed || this.states.get(key) !== state, + 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) => { state.child?.kill() state.mux?.dispose() if (state.phase !== 'failed') { @@ -238,11 +214,11 @@ export class WslHookRelayManager { cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS }) } - } + }) + await state.startup } - private async connect( - state: DistroState, + state: WslRelayDistroState, transport: MultiplexerTransport, child: ChildProcessWithoutNullStreams, instanceKey: string @@ -261,8 +237,6 @@ export class WslHookRelayManager { } 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 && @@ -275,7 +249,6 @@ export class WslHookRelayManager { }) } }) - const homeResult = (await mux.request(WSL_HOOK_FS_METHODS.home)) as { ok?: boolean home?: string @@ -293,52 +266,30 @@ export class WslHookRelayManager { state.guestHome = homeResult.home state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey) await runWslRelayGuestInstall(this.deps, 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 maybeRerunWslRelayGuestInstall(this.deps, state) }) - void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => { - // Fresh relays have nothing to replay; tolerate. - }) + void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {}) } - /** 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, + state: WslRelayDistroState, 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) + markWslRelayFailed(state, message, options, this.deps, this.recovery) } - private async resolveDefaultDistro(): Promise { - this.defaultDistro = await resolveWslHookDefaultDistro( - this.defaultDistro, - this.deps.listDistros - ) - return this.defaultDistro + const distro = await resolveWslDefaultDistro(this.defaultDistro, this.deps.listDistros) + this.defaultDistro = distro + return distro } } - export const wslHookRelayManager = new WslHookRelayManager() diff --git a/src/main/agent-hooks/wsl-hook-relay-state-machine.ts b/src/main/agent-hooks/wsl-hook-relay-state-machine.ts new file mode 100644 index 00000000000..04944712a57 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-state-machine.ts @@ -0,0 +1,72 @@ +import { FAILURE_COOLDOWN_MAX_MS } from './wsl-hook-relay-deps' + +type RelayFailureState = { + distro: string + phase: 'starting' | 'running' | 'failed' + failures: number + cooldownUntil: number + child?: unknown + mux?: { dispose(): void } + reinstallTimer?: ReturnType +} + +type RelayFailureDeps = { + warn(message: string): void +} + +type RelayRecovery = { + scheduleRestart(state: RelayFailureState): void +} + +export function markWslRelayFailed( + state: RelayFailureState, + message: string, + options: { cooldownBaseMs: number }, + deps: RelayFailureDeps, + recovery: RelayRecovery +): 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) + deps.warn(`[agent-hooks] WSL hook relay: ${message}`) + recovery.scheduleRestart(state) +} + +export function resumeWslStoppedRelays( + stopped: Map, + isDistroRunning: (distro: string) => Promise, + ensure: (distro: string, home: string | undefined) => void +): void { + const distros = [...stopped] + stopped.clear() + for (const [distro, home] of distros) { + void isDistroRunning(distro) + .then((running) => { + if (running) { + ensure(distro, home) + } + }) + .catch(() => undefined) + } +} + +export async function resolveWslDefaultDistro( + current: string | null, + listDistros: () => Promise +): Promise { + if (current) { + return current + } + try { + return (await listDistros())[0] ?? null + } catch { + return null + } +} diff --git a/src/main/agent-hooks/wsl-hook-relay-state.ts b/src/main/agent-hooks/wsl-hook-relay-state.ts new file mode 100644 index 00000000000..509231b97d9 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-state.ts @@ -0,0 +1,24 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +export type WslRelayDistroState = { + distro: string + phase: 'starting' | 'running' | 'failed' + child?: { kill: () => void } + mux?: SshChannelMultiplexer + guestHome?: string + codexHomePath?: string + guestEndpointFilePath?: string + opencodeOverlayDir?: string + opencode2OverlayDir?: string + piAgentDir?: string + ompStatusExtension?: string + launchKinds: Set<'pi' | 'omp'> + startup?: Promise + installation?: Promise + failures: number + cooldownUntil: number + connectedAt?: number + restartTimer?: ReturnType + reinstallTimer?: ReturnType + lastInstallAt?: number +} diff --git a/src/main/agent-hooks/wsl-pi-omp-guest-readiness.ts b/src/main/agent-hooks/wsl-pi-omp-guest-readiness.ts new file mode 100644 index 00000000000..fee76103a3a --- /dev/null +++ b/src/main/agent-hooks/wsl-pi-omp-guest-readiness.ts @@ -0,0 +1,49 @@ +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType, + type PiAgentKind +} from '../../shared/pi-agent-kind' +import type { TuiAgent } from '../../shared/tui-agent' +import { wslHookRelayManager, type WslHookRelayManager } from './wsl-hook-relay-manager' + +export async function awaitExplicitPiOmpGuestReadiness(args: { + isWsl: boolean + distro: string | null | undefined + codexHomePath?: string | null + launchAgent?: TuiAgent + launchCommand?: string + timeoutMs?: number + manager?: Pick< + WslHookRelayManager, + 'ensureForDistro' | 'getGuestEndpointFilePath' | 'getGuestAgentPath' + > +}): Promise { + if (!args.isWsl) { + return true + } + const kind: PiAgentKind | null = isPiCompatibleAgentType(args.launchAgent) + ? args.launchAgent + : detectExplicitPiAgentKindFromCommand(args.launchCommand) + if (kind !== 'pi' && kind !== 'omp') { + return true + } + const distro = args.distro ?? null + const manager = args.manager ?? wslHookRelayManager + let timer: ReturnType | undefined + try { + return await Promise.race([ + manager + .ensureForDistro(distro, args.codexHomePath, kind) + .then(() => + Boolean( + manager.getGuestEndpointFilePath(distro) && manager.getGuestAgentPath(distro, kind) + ) + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), args.timeoutMs ?? 10_000) + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 53755214238..47082d6e745 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -505,7 +505,7 @@ describe('registerPtyHandlers', () => { 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', // Why: bare WSL shells no longer create ~/.omp; only status extension is exported (#10196). - 'ORCA_OMP_STATUS_EXTENSION/p', + 'ORCA_OMP_STATUS_EXTENSION/u', 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' ]) ) diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 21fadb50f28..c36fa3e40f5 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => { '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' const ensureForDistro = vi .spyOn(wslHookRelayManager, 'ensureForDistro') - .mockImplementation(() => {}) + .mockImplementation(async () => {}) try { buildPtyHostEnv( @@ -213,7 +213,7 @@ describe('registerPtyHandlers', () => { agentStatusHooksEnabled: true } ) - expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome) + expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome, undefined) } finally { ensureForDistro.mockRestore() } diff --git a/src/main/ipc/pty-write-ipc-validation.test.ts b/src/main/ipc/pty-write-ipc-validation.test.ts index bf6082a2c07..ee05fc62c60 100644 --- a/src/main/ipc/pty-write-ipc-validation.test.ts +++ b/src/main/ipc/pty-write-ipc-validation.test.ts @@ -373,7 +373,7 @@ describe('registerPtyHandlers', () => { // reattach call — the manager owns the hooks/platform gating this spy stands in for. const ensureForDistro = vi .spyOn(wslHookRelayManager, 'ensureForDistro') - .mockImplementation(() => {}) + .mockImplementation(async () => {}) setLocalPtyProvider({ spawn: vi.fn(async () => ({ id: 'pty-wsl', isReattach: true, wslDistro: 'Ubuntu-24.04' })), write: vi.fn(), diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index ef8585a93a2..6186976d127 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -136,7 +136,11 @@ 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 - wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath) + const wslLaunchKind = + explicitPiAgentKind === 'pi' || explicitPiAgentKind === 'omp' + ? explicitPiAgentKind + : undefined + wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath, wslLaunchKind) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint @@ -222,6 +226,21 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION } + if (opts.isWsl && opts.agentStatusHooksEnabled) { + const distro = opts.wslDistro ?? null + if (explicitPiAgentKind === 'pi') { + const guestPiDir = wslHookRelayManager.getGuestAgentPath(distro, 'pi') + if (guestPiDir) { + baseEnv.ORCA_PI_SOURCE_AGENT_DIR = guestPiDir + } + } else if (explicitPiAgentKind === 'omp') { + const guestOmpExtension = wslHookRelayManager.getGuestAgentPath(distro, 'omp') + if (guestOmpExtension) { + baseEnv.ORCA_OMP_STATUS_EXTENSION = guestOmpExtension + } + } + } + // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. if (opts.skipCodexHomeEnv) { delete baseEnv.CODEX_HOME diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index d7d3f6b81a6..bf07eddf0bb 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -21,6 +21,7 @@ import type { GetSelectedCodexHomePath } from '../host-env/types' import { isCurrentPtyExit, ptyOwnership } from './ownership-state' import { localProvider } from './registry' import { clearProviderPtyState } from './state-cleanup' +import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' export function configureLocalPtyProvider(args: { runtime?: OrcaRuntimeService @@ -63,6 +64,13 @@ export function configureLocalPtyProvider(args: { launchAgent: ctx?.launchAgent, launchCommand: ctx?.command }) + await awaitExplicitPiOmpGuestReadiness({ + isWsl: ctx?.isWsl === true, + distro: ctx?.wslDistro, + codexHomePath: selectedCodexHomePath, + launchAgent: ctx?.launchAgent, + launchCommand: ctx?.command + }) const env = buildPtyHostEnv(id, baseEnv, { isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index fb95fb2b5a0..2bd10feaab6 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -42,6 +42,7 @@ import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' import { clearProviderPtyState } from '../provider/state-cleanup' +import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness' import type { RuntimePtySpawnState } from './spawn-state' export async function prepareRuntimePtySpawn( @@ -265,6 +266,13 @@ export async function prepareRuntimePtySpawn( launchAgent: args.launchAgent, launchCommand: ctx.launchCommand }) + await awaitExplicitPiOmpGuestReadiness({ + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + distro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + codexHomePath: ctx.selectedCodexHomePath, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + launchCommand: ctx.launchCommand + }) ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, { isPackaged: getAppEnvironment().isPackaged(), resourcesPath: process.resourcesPath, diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 7b99ddc98ee..fa4d234bd0d 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -98,7 +98,8 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_WSL_HOOK_RELAY_VERSION/u', 'ORCA_WSL_HOOK_INSTANCE/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', - 'ORCA_OMP_STATUS_EXTENSION/p', + `ORCA_OMP_STATUS_EXTENSION/${env.ORCA_OMP_STATUS_EXTENSION?.startsWith('/') ? 'u' : 'p'}`, + ...(env.ORCA_PI_SOURCE_AGENT_DIR?.startsWith('/') ? ['ORCA_PI_SOURCE_AGENT_DIR/u'] : []), `${ORCA_IMAGE_PROTOCOL_ENV}/u`, 'ORCA_OMP_FRESH_CONFIG/p', ...worktreeSetupWslenvEntries(env) diff --git a/src/relay/wsl-install-plugins-handler.test.ts b/src/relay/wsl-install-plugins-handler.test.ts index 9a230b768d3..7e12e4c6435 100644 --- a/src/relay/wsl-install-plugins-handler.test.ts +++ b/src/relay/wsl-install-plugins-handler.test.ts @@ -43,6 +43,20 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues }) }) + it('materializes the requested Pi extension in the guest home', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst-pi' + }) + const source = '// @orca-managed-pi-extension\nexport default {}\n' + const res = install({ piExtensionSource: source, launchKind: 'pi' }) + expect(res.overlayDirs.pi).toBe(join(home, '.pi', 'agent')) + const extension = join(home, '.pi', 'agent', 'extensions', 'orca-agent-status.ts') + expect(readFileSync(extension, 'utf8')).toContain(source) + }) + }) + it('writes the OpenCode 2 plugin to its separate overlay', () => { withHome((home) => { const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index 21eaf16db87..ca5c515a9e8 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -21,7 +21,7 @@ export type InstallPluginsResult = { omp: boolean primeAgent: boolean } - overlayDirs: { opencode?: string; opencode2?: string } + overlayDirs: { opencode?: string; opencode2?: string; pi?: string; omp?: string } } export type InstallPluginsHandler = (params: Record) => InstallPluginsResult @@ -64,6 +64,10 @@ export function createInstallPluginsHandler( primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined }) let opencodeDir: string | undefined + const launchKind = + params.launchKind === 'pi' || params.launchKind === 'omp' ? params.launchKind : undefined + let piDir: string | undefined + let ompDir: string | undefined if (pluginOverlay.hasOpenCodeSource()) { // An omitted source leaves the manager's cache untouched, so it counts as unchanged. const incoming = typeof opencode === 'string' ? opencode : null @@ -112,6 +116,21 @@ export function createInstallPluginsHandler( : null } } + // Materialize only the explicitly requested agent. Bare shells must not create + // ~/.pi/agent or ~/.omp/agent (#10196). + if (launchKind === 'pi' || launchKind === 'omp') { + const source = launchKind === 'pi' ? pi : omp + if (typeof source === 'string') { + const result = pluginOverlay.materializePi(`wsl-${launchKind}`, undefined, launchKind, { + materializeDefaultHome: true + }) + if (launchKind === 'pi') { + piDir = result?.sourceAgentDir + } else { + ompDir = result?.statusExtensionPath + } + } + } return { installed: { opencode: pluginOverlay.hasOpenCodeSource(), @@ -122,7 +141,9 @@ export function createInstallPluginsHandler( }, overlayDirs: { ...(opencodeDir ? { opencode: opencodeDir } : {}), - ...(opencode2Dir ? { opencode2: opencode2Dir } : {}) + ...(opencode2Dir ? { opencode2: opencode2Dir } : {}), + ...(piDir ? { pi: piDir } : {}), + ...(ompDir ? { omp: ompDir } : {}) } } }