From 605a4ef86863f7f2fb38af2dc85e0140108213fd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:42:14 -0700 Subject: [PATCH] fix(omp): start new tasks without auto-resuming old sessions (#20622) * wip(omp): prove fresh settings overlay without redirecting storage * fix(omp): guard fresh launches with execution-host settings * fix(omp): preserve unmodelled shell launch commands * test(omp): consolidate shell fixture path import * preserve fresh OMP launch status * test: cover preserved OMP launch status * fix: recognize wrapped fresh OMP launches * chore(ci): refresh validation against fixed main baseline * fix(omp): recognize generated fresh launch guards across shells * fix(omp): preserve draft status and clear prefill across Unix shells * fix(omp): run cmd draft cleanup after either guard branch * fix(omp): launch drafts safely with nounset enabled * fix(omp): select draft shell without parser diagnostics * test(omp): await relay environment augmentation --- docs/reference/omp-fresh-launch.md | 51 +++++++++ src/main/ipc/pty-ipc-mock-registry.ts | 1 + .../ipc/pty-spawn-env-agent-overlays.test.ts | 10 +- .../ipc/pty-spawn-env-terminal-basics.test.ts | 17 +++ src/main/ipc/pty/host-env/assembly.ts | 3 + .../pi/titlebar-extension-service.test.ts | 2 + src/main/pi/titlebar-extension-service.ts | 23 +++- src/main/pty/wsl-orca-env.test.ts | 2 + src/main/pty/wsl-orca-env.ts | 2 +- .../omp-fresh-launch-environment.test.ts | 107 ++++++++++++++++++ src/relay/plugin-overlay.ts | 7 ++ src/relay/relay-agent-hook-runtime.ts | 9 +- src/shared/agent-process-recognition.test.ts | 39 +++++++ src/shared/agent-process-recognition.ts | 4 + src/shared/omp-draft-launch-shell.test.ts | 72 ++++++++++++ src/shared/omp-draft-launch-windows.test.ts | 76 +++++++++++++ src/shared/omp-draft-launch.ts | 22 ++++ src/shared/omp-fresh-config.ts | 18 +++ src/shared/omp-fresh-launch-shell.test.ts | 73 ++++++++++++ src/shared/omp-fresh-launch.test.ts | 68 +++++++++++ src/shared/omp-fresh-launch.ts | 83 ++++++++++++++ src/shared/pi-agent-kind.ts | 4 + src/shared/tui-agent-startup.test.ts | 4 +- src/shared/tui-agent-startup.ts | 28 +++-- tests/tools/omp-fresh-launch-windows-smoke.ts | 77 +++++++++++++ .../tools/omp-fresh-session-runtime-smoke.mjs | 75 ++++++++++++ 26 files changed, 854 insertions(+), 23 deletions(-) create mode 100644 docs/reference/omp-fresh-launch.md create mode 100644 src/relay/omp-fresh-launch-environment.test.ts create mode 100644 src/shared/omp-draft-launch-shell.test.ts create mode 100644 src/shared/omp-draft-launch-windows.test.ts create mode 100644 src/shared/omp-draft-launch.ts create mode 100644 src/shared/omp-fresh-config.ts create mode 100644 src/shared/omp-fresh-launch-shell.test.ts create mode 100644 src/shared/omp-fresh-launch.test.ts create mode 100644 src/shared/omp-fresh-launch.ts create mode 100644 tests/tools/omp-fresh-launch-windows-smoke.ts create mode 100644 tests/tools/omp-fresh-session-runtime-smoke.mjs diff --git a/docs/reference/omp-fresh-launch.md b/docs/reference/omp-fresh-launch.md new file mode 100644 index 00000000000..ea667938172 --- /dev/null +++ b/docs/reference/omp-fresh-launch.md @@ -0,0 +1,51 @@ +# Fresh OMP launches + +Orca's new-session and draft launch plans apply a one-time `--config` overlay +containing `autoResume: false`. OMP's configured session directory, settings, +authentication and extensions remain in their usual locations. Saved launch +configuration omits the overlay so explicit resume keeps its normal semantics. +Custom commands with session selectors, unknown flags, positional arguments or +shell compounds are left unchanged. + +The execution host creates the overlay. Local and WSL terminals use Orca userData +(with WSLENV path translation); SSH relays use their own managed directory. Config +creation is independent of status-hook preferences and does not require plugin +source installation. An unavailable file produces a terminal diagnostic and skips +OMP. Filesystem failures do not prevent unrelated agents or bare shells starting. + +The guard invokes OMP in the current shell, preserving functions, aliases and the +managed status wrapper. A nonzero agent exit never triggers a second launch. +POSIX commands also support fish; environment presence is checked before expansion +so an old host under `set -u` reports the same missing-settings diagnostic. + +## Mixed versions + +The existing command and environment transport carries the launch unchanged; no +new RPC or stream opcode is introduced. A new host exports the path before shell +startup, including bare shells that receive an OMP command later. An old client +continues its existing launch behavior on a new host. A new fresh-launch command +on an old host without the managed environment fails visibly and requests a host +update and terminal restart. It must not silently fall back to OMP auto-resume. + +## Verification + +`src/shared/omp-fresh-launch-shell.test.ts` runs actual available bash, zsh and fish +shells, checking exact argv, a single invocation, nonzero exit, deleted settings +and an absent environment variable. `src/relay/omp-fresh-launch-environment.test.ts` +checks the guarded command through relay environment assembly and the actual OMP +shell wrapper, retaining both extension and config arguments and prefill. +Local host assembly tests recognize guarded POSIX, cmd and PowerShell commands. + +Run the actual OMP storage smoke against a read-only OMP checkout: + +```sh +ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-fresh-session-runtime-smoke.mjs /path/to/oh-my-pi +``` + +For Windows, bundle `tests/tools/omp-fresh-launch-windows-smoke.ts` with +`bun build --target=node --outfile=/tmp/omp-fresh-windows-smoke.mjs`, transfer the +bundle to the host and run it using Node with `ORCA_BACKGROUND_LAUNCH=1`. +The smoke uses temporary files and process-local environment only. Both cmd and +PowerShell must pass existing/missing/unset/directory settings cases, preserving +exit 17 for the single successful launch and returning exit 1 without launching +when settings are unavailable. This passed on Windows host `awin` on 2026-09-14. diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index b411febfa5d..11afd2df60a 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -129,6 +129,7 @@ export const agentHookServerModuleMock = () => ({ export const piTitlebarExtensionModuleMock = () => ({ piTitlebarExtensionService: { buildPtyEnv: piBuildPtyEnvMock, + buildFreshOmpEnv: () => ({ ORCA_OMP_FRESH_CONFIG: '/tmp/orca-fresh-session.yml' }), clearPty: piClearPtyMock } }) diff --git a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts index 7b2ff82b654..1595835801f 100644 --- a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts +++ b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts @@ -1,3 +1,4 @@ +import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { readFileSyncMock, @@ -293,14 +294,19 @@ describe('registerPtyHandlers', () => { }) expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/default-pi-agent') }) - it('threads command: "omp" through to piBuildPtyEnv and emits OMP status metadata', async () => { + it.each([ + 'omp', + withFreshOmpLaunch('omp', 'posix'), + withFreshOmpLaunch('omp', 'powershell'), + withFreshOmpLaunch('omp', 'cmd') + ])('threads OMP command %s through to the host integration', async (command) => { // Why: OMP launches emit ORCA_OMP_* shadow vars, not Pi-named ones; only PI_CODING_AGENT_DIR stays (OMP's own binary reads it). const env = await spawnAndGetEnv( undefined, { PI_CODING_AGENT_DIR: '/tmp/user-omp-agent' }, undefined, undefined, - 'omp' + command ) expect(piBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), 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 c83028075a4..e8a6595d4a5 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,3 +1,4 @@ +import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { spawnMock } from './pty-ipc-mock-registry' import { BUNDLED_CLI_PATH, TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' @@ -58,6 +59,22 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it('prepares fresh OMP settings even when status hooks are disabled', () => { + const env = buildPtyHostEnv( + 'fresh-without-hooks', + { ORCA_OMP_FRESH_CONFIG: '/other-host/stale.yml' }, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false, + launchCommand: withFreshOmpLaunch('omp', 'posix') + } + ) + expect(env.ORCA_OMP_FRESH_CONFIG).toBe('/tmp/orca-fresh-session.yml') + expect(env.ORCA_OMP_STATUS_EXTENSION).toBeUndefined() + }) + it('routes headless browser launches through the owning Orca workspace', () => { const inheritedBrowser = process.env.BROWSER delete process.env.BROWSER diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 9f9bbae0e43..c3aa7d7379f 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -194,6 +194,9 @@ export function buildPtyHostEnv( overlay: 'ORCA_OMP_CODING_AGENT_DIR', source: 'ORCA_OMP_SOURCE_AGENT_DIR' }) + if (shouldPrepareOmpShadow) { + Object.assign(baseEnv, piTitlebarExtensionService.buildFreshOmpEnv()) + } delete baseEnv.ORCA_OMP_STATUS_EXTENSION delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 3c3347b3b4f..1240d1859a8 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -275,6 +275,8 @@ describe('PiTitlebarExtensionService', () => { const content = 'agent.db credentials' expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + expect(readFileSync(env.ORCA_OMP_FRESH_CONFIG, 'utf8')).toBe('autoResume: false\n') + expect(env.ORCA_OMP_FRESH_CONFIG.startsWith(userDataDir)).toBe(true) expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(piHome) expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(join(piHome, 'extensions', 'orca-agent-status.ts')) expect(existsSync(sourcePath)).toBe(false) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 0209c6232f5..64a021603f7 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,3 +1,4 @@ +import { materializeOmpFreshConfig } from '../../shared/omp-fresh-config' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -174,16 +175,24 @@ export class PiTitlebarExtensionService { } } + buildFreshOmpEnv(): Record { + return { + ORCA_OMP_FRESH_CONFIG: materializeOmpFreshConfig( + join(getAppEnvironment().getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR) + ) + } + } + buildPtyEnv( ptyId: string, existingAgentDir: string | undefined, kind: PiAgentKind, options?: { materializeDefaultHome?: boolean; configDirName?: string } ): Record { - // The caller resolves the effective launch environment. Reading the - // daemon's ambient PI_CONFIG_DIR here can select the host profile for a - // guest/WSL launch whose environment has not been hydrated yet. - const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind, options?.configDirName) + const freshConfigEnv = kind === 'omp' ? this.buildFreshOmpEnv() : {} + // The caller resolves the effective launch environment before this point. + const sourceAgentDir = + existingAgentDir || getDefaultPiAgentDir(kind, options?.configDirName) if (kind !== 'prime-agent') { try { this.safeRemoveOverlay(this.getPtyOverlayDir(ptyId, kind), kind) @@ -203,7 +212,9 @@ export class PiTitlebarExtensionService { if (kind === 'omp') { const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource) - return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {} + return statusExtensionPath + ? { ...freshConfigEnv, ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } + : freshConfigEnv } return {} } @@ -213,7 +224,7 @@ export class PiTitlebarExtensionService { } const installed = this.installManagedExtensions(sourceAgentDir, kind) - const env: Record = {} + const env: Record = { ...freshConfigEnv } if (kind === 'omp') { env.ORCA_OMP_SOURCE_AGENT_DIR = installed.sourceAgentDir if (installed.statusExtensionPath) { diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 4f655b1e33a..e9004b7df8d 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -63,6 +63,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', @@ -87,6 +88,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') + expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p') expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 612efd8b943..7b99ddc98ee 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -99,8 +99,8 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_WSL_HOOK_INSTANCE/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', 'ORCA_OMP_STATUS_EXTENSION/p', - // A protocol name, never a path; in-guest agents read it to pick an image encoder. `${ORCA_IMAGE_PROTOCOL_ENV}/u`, + 'ORCA_OMP_FRESH_CONFIG/p', ...worktreeSetupWslenvEntries(env) ] applyWslenvPassthrough(env, passthroughEntries) diff --git a/src/relay/omp-fresh-launch-environment.test.ts b/src/relay/omp-fresh-launch-environment.test.ts new file mode 100644 index 00000000000..ec36952367e --- /dev/null +++ b/src/relay/omp-fresh-launch-environment.test.ts @@ -0,0 +1,107 @@ +import { runProcess } from '../shared/child-process/run-process' +import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeOs from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import { PtyHandler } from './pty-handler' +import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' +import { PluginOverlayManager } from './plugin-overlay' +import { withFreshOmpLaunch } from '../shared/omp-fresh-launch' + +const state = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +afterEach(() => { + vi.restoreAllMocks() + if (state.home) { + rmSync(state.home, { recursive: true, force: true }) + } +}) + +it('prepares the execution host OMP config and status extension for a guarded launch', async () => { + state.home = mkdtempSync(join(tmpdir(), 'orca-relay-fresh-')) + const dispatcher = new RelayDispatcher(() => {}) + const handler = new PtyHandler(dispatcher) + const augment = vi.spyOn(handler, 'addEnvAugmenter') + const source = vi.spyOn(PluginOverlayManager.prototype, 'hasPiSource').mockReturnValue(true) + const original = PluginOverlayManager.prototype.materializePi + vi.spyOn(PluginOverlayManager.prototype, 'materializePi').mockImplementation(function ( + this: PluginOverlayManager, + ...args + ) { + this.setSources({ ompExtensionSource: '// Orca managed test status extension' }) + return original.apply(this, args) + }) + const runtime = new RelayAgentHookRuntime( + dispatcher, + handler, + join(state.home, 'relay.sock'), + join(state.home, 'hooks') + ) + await runtime.start() + try { + const environment = await augment.mock.calls[1][0]({ + id: 'fresh-pane', + shell: '/bin/bash', + env: { HOME: state.home }, + command: withFreshOmpLaunch('omp', 'posix') + }) + expect(readFileSync(environment.ORCA_OMP_FRESH_CONFIG, 'utf8')).toBe('autoResume: false\n') + expect(existsSync(environment.ORCA_OMP_STATUS_EXTENSION)).toBe(true) + expect(environment.ORCA_OMP_SOURCE_AGENT_DIR).toBe(join(state.home, '.omp', 'agent')) + expect(environment.PI_CODING_AGENT_DIR).toBeUndefined() + expect(environment.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + if (process.platform !== 'win32' && existsSync('/bin/bash')) { + writeFileSync( + join(state.home, 'omp'), + '#!/bin/sh\nprintf "%s\\n" "$@" "$ORCA_OMP_PREFILL"\nexit 17\n', + { mode: 0o755 } + ) + const result = await runProcess({ + program: '/bin/bash', + args: [ + '--noprofile', + '--norc', + '-c', + `${getPosixOmpShellWrapper()}\n${withFreshOmpLaunch('omp', 'posix')}` + ], + cwd: state.home, + env: { + ...process.env, + ...environment, + PATH: state.home + delimiter + process.env.PATH, + ORCA_OMP_PREFILL: 'remote draft' + } + }) + expect(result.code).toBe(17) + expect(result.stdout.trim().split('\n')).toEqual([ + '--extension', + environment.ORCA_OMP_STATUS_EXTENSION, + '--config', + environment.ORCA_OMP_FRESH_CONFIG, + 'remote draft' + ]) + } + + source.mockReturnValue(false) + expect( + augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' }) + ).toEqual({}) + } finally { + runtime.stop() + dispatcher.dispose() + } +}) + +it('fails the config guard safely when the relay-owned directory cannot be created', () => { + state.home = mkdtempSync(join(tmpdir(), 'orca-relay-fresh-fail-')) + const blocked = join(state.home, 'file') + writeFileSync(blocked, '') + const manager = new PluginOverlayManager({ homeDir: blocked }) + expect(manager.materializeOmpFreshConfig()).toBe('') +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 1303b5ac231..2e777898b7b 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,4 @@ +import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed // extension files installed into the remote agent homes. Host paths from the @@ -275,6 +276,12 @@ export class PluginOverlayManager { } } + materializeOmpFreshConfig(): string { + return materializeOmpFreshConfig( + join(this.homeDir, RELAY_HOOKS_DIR, OMP_MANAGED_STATUS_EXTENSION_DIR) + ) + } + /** Install the Pi/OMP status extension into the remote real agent dir. * `kind` selects which Pi-compatible agent's default dir to use when * `existingAgentDir` is not supplied. diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 71402586b86..1f503ec8a82 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -94,9 +94,6 @@ export class RelayAgentHookRuntime { } } } - if (!this.pluginOverlay.hasPiSource()) { - return env - } const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) const explicitKind = isPiCompatibleAgentType(context.launchAgent) ? context.launchAgent @@ -106,6 +103,12 @@ export class RelayAgentHookRuntime { const kind = explicitKind ?? 'pi' const hasLaunchCommand = typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 + if (kind === 'omp' || !hasLaunchCommand) { + env.ORCA_OMP_FRESH_CONFIG = this.pluginOverlay.materializeOmpFreshConfig() + } + if (!this.pluginOverlay.hasPiSource()) { + return env + } if (kind === 'pi') { const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'pi') const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index d97d334d6d3..7a9e961e292 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest' +import { withFreshOmpLaunch } from './omp-fresh-launch' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { detectExplicitPiAgentKindFromCommand } from './pi-agent-kind' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -16,6 +19,42 @@ describe('agent process recognition', () => { expect(isRecognizedAgentType('codex-aarch64-ap')).toBe(true) }) + it.each(['posix', 'powershell', 'cmd'] as const)( + 'recognizes fresh OMP commands and drafts in %s', + (shell) => { + for (const command of ['omp', 'omp launch', 'omp --model example']) { + const generated = withFreshOmpLaunch(command, shell) + expect(recognizeAgentProcessFromCommandLine(generated)).toEqual({ + agent: 'omp', + processName: 'omp' + }) + expect(detectExplicitPiAgentKindFromCommand(generated)).toBe('omp') + } + const draft = buildAgentDraftLaunchPlan({ + agent: 'omp', + draft: 'task', + cmdOverrides: {}, + platform: 'linux', + shell + }) + expect(recognizeAgentProcessFromCommandLine(draft?.launchCommand)).toEqual({ + agent: 'omp', + processName: 'omp' + }) + expect(detectExplicitPiAgentKindFromCommand(draft?.launchCommand)).toBe('omp') + } + ) + + it('does not classify embedded fresh-launch text as an agent command', () => { + for (const command of [ + `echo 'omp --config "$ORCA_OMP_FRESH_CONFIG"'`, + `echo '${withFreshOmpLaunch('omp', 'posix')}'` + ]) { + expect(recognizeAgentProcessFromCommandLine(command)).toBeNull() + expect(detectExplicitPiAgentKindFromCommand(command)).toBeNull() + } + }) + it('recognizes the OpenClaude foreground process', () => { expect(recognizeAgentProcess('/usr/local/bin/openclaude')).toEqual({ agent: 'openclaude', diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index c2755993906..bfc8a6f240f 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -4,6 +4,7 @@ import type { AgentType } from './agent-status-types' import type { TuiAgent } from './tui-agent' import { filterHeadlessOneShotAgentCommand } from './agent-headless-command' import { getFirstCommandToken } from './command-token-scanner' +import { isFreshOmpLaunchCommand } from './omp-fresh-launch' export type RecognizedAgentProcess = { agent: TuiAgent; processName: string } @@ -286,6 +287,9 @@ export function recognizeAgentProcessFromCommandLine( if (!commandLine) { return null } + if (isFreshOmpLaunchCommand(commandLine)) { + return recognizedAgentForProcess('omp') + } const keep = options?.includeHeadlessOneShot === true const tokens = tokenizeCommandLine(commandLine) const firstNormalized = normalizeProcessName(tokens[0]) diff --git a/src/shared/omp-draft-launch-shell.test.ts b/src/shared/omp-draft-launch-shell.test.ts new file mode 100644 index 00000000000..889b9020c52 --- /dev/null +++ b/src/shared/omp-draft-launch-shell.test.ts @@ -0,0 +1,72 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' + +const shells = ['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'].filter( + (shell) => + process.platform !== 'win32' && + (process.env.PATH ?? '') + .split(delimiter) + .some((directory) => existsSync(join(directory, shell))) +) + +it.each( + shells.flatMap((shell) => + shell === 'fish' + ? [{ shell, nounset: false }] + : [false, true].map((nounset) => ({ shell, nounset })) + ) +)( + 'clears the draft and preserves status in $shell (nounset=$nounset)', + async ({ shell, nounset }) => { + const root = await mkdtemp(join(tmpdir(), 'orca-omp-draft-')) + try { + const config = join(root, 'fresh settings.yml') + const calls = join(root, 'calls') + await writeFile(config, 'autoResume: false\n') + const plan = buildAgentDraftLaunchPlan({ + agent: 'omp', + draft: 'task with spaces', + cmdOverrides: {}, + platform: 'linux' + }) + if (!plan) { + throw new Error('Expected draft plan') + } + const define = + shell === 'fish' + ? 'function omp; printf "%s\\n" "$ORCA_OMP_PREFILL" >> "$CAPTURE"; printf agent-stderr >&2; return 17; end; ' + : 'omp() { printf "%s\\n" "$ORCA_OMP_PREFILL" >> "$CAPTURE"; printf agent-stderr >&2; return 17; }; ' + const observe = + shell === 'fish' + ? '; set -l result $status; set -q ORCA_OMP_PREFILL; and exit 91; exit $result' + : '; result=$?; test -z "${ORCA_OMP_PREFILL+x}" || exit 91; exit "$result"' + for (const present of [true, false]) { + if (!present) { + await rm(config) + } + const result = await runProcess({ + program: shell, + args: ['-c', (nounset ? 'set -u; ' : '') + define + plan.launchCommand + observe], + cwd: root, + env: { ...process.env, ...plan.env, ORCA_OMP_FRESH_CONFIG: config, CAPTURE: calls } + }) + expect(result.code, result.stderr).toBe(present ? 17 : 1) + expect(result.stderr).toContain( + present ? 'agent-stderr' : 'fresh OMP settings are unavailable' + ) + if (present) { + expect(result.stderr).toBe('agent-stderr') + } + expect(result.stderr).not.toContain('command substitutions not allowed') + expect(await readFile(calls, 'utf8')).toBe('task with spaces\n') + } + } finally { + await rm(root, { recursive: true, force: true }) + } + } +) diff --git a/src/shared/omp-draft-launch-windows.test.ts b/src/shared/omp-draft-launch-windows.test.ts new file mode 100644 index 00000000000..5a09769f2e9 --- /dev/null +++ b/src/shared/omp-draft-launch-windows.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' + +it.skipIf(process.platform !== 'win32').each(['cmd', 'powershell'] as const)( + 'clears the draft and preserves status in native %s', + async (shell) => { + const root = await mkdtemp(join(tmpdir(), 'orca-omp-draft-')) + try { + const config = join(root, 'fresh (%ORCA_EXPANSION_PROBE%) & settings!.yml') + const calls = join(root, 'calls') + await writeFile( + join(root, 'omp.cmd'), + '@echo off\r\necho %ORCA_OMP_PREFILL%>>"%CAPTURE%"\r\nexit /b 17\r\n' + ) + const plan = buildAgentDraftLaunchPlan({ + agent: 'omp', + draft: 'task with spaces', + cmdOverrides: {}, + platform: 'win32', + shell + }) + if (!plan) { + throw new Error('Expected draft plan') + } + for (const state of ['present', 'missing', 'unset', 'directory']) { + await rm(config, { force: true, recursive: true }) + await rm(calls, { force: true }) + if (state === 'present') { + await writeFile(config, 'autoResume: false\n') + } + if (state === 'directory') { + await mkdir(config) + } + const env = { + ...process.env, + ...plan.env, + ORCA_OMP_FRESH_CONFIG: state === 'unset' ? undefined : config, + CAPTURE: calls, + ORCA_EXPANSION_PROBE: 'unexpected' + } + const result = + shell === 'cmd' + ? await runProcess({ + program: 'cmd.exe', + args: ['/d', '/q'], + cwd: root, + env, + input: `${plan.launchCommand}\r\nset "orca_result=%errorlevel%"\r\nif defined ORCA_OMP_PREFILL exit 91\r\nexit %orca_result%\r\n` + }) + : await runProcess({ + program: 'powershell.exe', + args: [ + '-NoProfile', + '-NonInteractive', + '-Command', + `function omp { Add-Content -LiteralPath $env:CAPTURE -Value $env:ORCA_OMP_PREFILL; $global:LASTEXITCODE = 17 }; ${plan.launchCommand}; $result = $LASTEXITCODE; if (Test-Path Env:ORCA_OMP_PREFILL) { exit 91 }; exit $result` + ], + cwd: root, + env + }) + expect(result.code, JSON.stringify({ state, ...result })).toBe(state === 'present' ? 17 : 1) + if (state === 'present') { + expect((await readFile(calls, 'utf8')).trim()).toBe('task with spaces') + } else { + await expect(readFile(calls)).rejects.toMatchObject({ code: 'ENOENT' }) + } + } + } finally { + await rm(root, { recursive: true, force: true }) + } + } +) diff --git a/src/shared/omp-draft-launch.ts b/src/shared/omp-draft-launch.ts new file mode 100644 index 00000000000..57c988500c1 --- /dev/null +++ b/src/shared/omp-draft-launch.ts @@ -0,0 +1,22 @@ +import { + clearEnvCommand, + commandSeparator, + quoteStartupArg, + type AgentStartupShell +} from './tui-agent-startup-shell' + +const FUNCTION_NAME = '__orca_omp_draft' +export const OMP_DRAFT_LAUNCH_PREFIX = `eval 'builtin --query set' 2>/dev/null && eval 'function ${FUNCTION_NAME}; ` + +/** Clear the calling shell's prefill without replacing the agent's exit status. */ +export function withOmpDraftCleanup(command: string, shell: AgentStartupShell): string { + if (shell !== 'posix') { + // cmd otherwise binds the cleanup to the guard's else branch. + const launch = shell === 'cmd' ? `( ${command} )` : command + return `${launch}${commandSeparator(shell)}${clearEnvCommand('ORCA_OMP_PREFILL', shell)}` + } + const fish = `function ${FUNCTION_NAME}; ${command}; set -l __orca_status $status; set -e -g ORCA_OMP_PREFILL; return $__orca_status; end` + const posix = `${FUNCTION_NAME}() { ${command}; set -- "$?"; unset ORCA_OMP_PREFILL; return "$1"; }` + // Fish supports builtin --query; other shells reject it without parsing the wrong definition. + return `eval 'builtin --query set' 2>/dev/null && eval ${quoteStartupArg(fish, 'posix')} || eval ${quoteStartupArg(posix, 'posix')}; ${FUNCTION_NAME}` +} diff --git a/src/shared/omp-fresh-config.ts b/src/shared/omp-fresh-config.ts new file mode 100644 index 00000000000..72b04942a25 --- /dev/null +++ b/src/shared/omp-fresh-config.ts @@ -0,0 +1,18 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { OMP_FRESH_CONFIG_FILENAME, OMP_FRESH_CONFIG_SOURCE } from './omp-fresh-launch' + +/** An empty path fails the launch guard without preventing unrelated terminal launches. */ +export function materializeOmpFreshConfig(directory: string): string { + try { + const configPath = join(directory, OMP_FRESH_CONFIG_FILENAME) + if (existsSync(configPath) && readFileSync(configPath, 'utf8') === OMP_FRESH_CONFIG_SOURCE) { + return configPath + } + mkdirSync(directory, { recursive: true }) + writeFileSync(configPath, OMP_FRESH_CONFIG_SOURCE) + return configPath + } catch { + return '' + } +} diff --git a/src/shared/omp-fresh-launch-shell.test.ts b/src/shared/omp-fresh-launch-shell.test.ts new file mode 100644 index 00000000000..ebdc7581a9a --- /dev/null +++ b/src/shared/omp-fresh-launch-shell.test.ts @@ -0,0 +1,73 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { buildAgentStartupPlan } from './tui-agent-startup' + +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } +}) +const shells = ['bash', 'zsh', 'fish'].filter( + (shell) => + process.platform !== 'win32' && + (process.env.PATH ?? '') + .split(delimiter) + .some((directory) => existsSync(join(directory, shell))) +) +it.each(shells)( + 'preserves same-shell function argv, one launch and exit status in %s', + async (shell) => { + const root = await mkdtemp(join(tmpdir(), 'orca-fresh-shell-')) + roots.push(root) + const config = join(root, 'fresh settings.yml') + const capture = join(root, 'argv') + await writeFile(config, 'autoResume: false\n') + const plan = buildAgentStartupPlan({ + agent: 'omp', + prompt: 'task with spaces', + cmdOverrides: {}, + platform: 'linux', + isRemote: true + }) + expect(plan).not.toBeNull() + const define = + shell === 'fish' + ? 'function omp; printf "%s\\n" $argv >> "$CAPTURE"; return 17; end; ' + : 'omp() { printf "%s\\n" "$@" >> "$CAPTURE"; return 17; }; ' + const result = await runProcess({ + program: shell, + args: ['-c', define + plan!.launchCommand], + cwd: root, + env: { ...process.env, ORCA_OMP_FRESH_CONFIG: config, CAPTURE: capture } + }) + expect(result.code).toBe(17) + expect(await readFile(capture, 'utf8')).toBe(`--config\n${config}\ntask with spaces\n`) + await rm(capture) + await rm(config) + const missing = await runProcess({ + program: shell, + args: ['-c', define + plan!.launchCommand], + cwd: root, + env: { ...process.env, ORCA_OMP_FRESH_CONFIG: config, CAPTURE: capture } + }) + expect(missing.code).not.toBe(0) + expect(missing.stderr).toContain('fresh OMP settings are unavailable') + await expect(readFile(capture)).rejects.toMatchObject({ code: 'ENOENT' }) + const unsetEnv: NodeJS.ProcessEnv = { ...process.env, CAPTURE: capture } + delete unsetEnv.ORCA_OMP_FRESH_CONFIG + const unset = await runProcess({ + program: shell, + args: ['-c', (shell === 'fish' ? '' : 'set -u; ') + define + plan!.launchCommand], + cwd: root, + env: unsetEnv + }) + expect(unset.code).not.toBe(0) + expect(unset.stderr).toContain('fresh OMP settings are unavailable') + await expect(readFile(capture)).rejects.toMatchObject({ code: 'ENOENT' }) + } +) diff --git a/src/shared/omp-fresh-launch.test.ts b/src/shared/omp-fresh-launch.test.ts new file mode 100644 index 00000000000..eb8c94e2e5c --- /dev/null +++ b/src/shared/omp-fresh-launch.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { withFreshOmpLaunch } from './omp-fresh-launch' +import { buildAgentStartupPlan } from './tui-agent-startup' + +describe('OMP fresh launch intent', () => { + it.each(['omp', 'omp launch', 'omp --model provider/model', 'omp --config user.yml'])( + '%s adds the final overlay', + (command) => { + expect(withFreshOmpLaunch(command, 'posix')).toContain( + `${command} --config "$ORCA_OMP_FRESH_CONFIG"` + ) + } + ) + it.each([ + 'omp --resume id', + 'omp -r id', + 'omp --continue', + 'omp -c', + 'omp --session-dir /custom', + 'omp --no-session', + 'omp --fork id', + 'omp models', + 'omp config', + 'omp wt', + 'omp --help', + 'omp --unknown foo', + 'omp --model', + 'omp -- hello', + 'echo omp', + 'omp && echo hi', + 'omp --model foo;', + 'omp --model $(preferred-model)', + 'omp --model `preferred-model`' + ])('preserves %s', (command) => { + expect(withFreshOmpLaunch(command, 'posix')).toBe(command) + }) + it.each(['cmd', 'powershell'] as const)('preserves compound values in %s', (shell) => { + const command = 'omp --model foo&' + expect(withFreshOmpLaunch(command, shell)).toBe(command) + }) + it('quotes the host config path for each Windows shell', () => { + expect(withFreshOmpLaunch('omp', 'powershell')).toContain( + 'omp --config "$env:ORCA_OMP_FRESH_CONFIG"' + ) + expect(withFreshOmpLaunch('omp', 'cmd')).toContain('omp --config "%ORCA_OMP_FRESH_CONFIG%"') + }) + it('keeps fresh intent out of saved resume command and environment', () => { + const plan = buildAgentStartupPlan({ + agent: 'omp', + prompt: 'new task', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.launchCommand).toContain('--config "$ORCA_OMP_FRESH_CONFIG"') + expect(JSON.stringify(plan?.launchConfig)).not.toContain('ORCA_OMP_FRESH_CONFIG') + expect(plan?.env).toBeUndefined() + }) + it('requires host-owned configuration for fresh SSH launches', () => { + const plan = buildAgentStartupPlan({ + agent: 'omp', + prompt: 'new task', + cmdOverrides: {}, + platform: 'linux', + isRemote: true + }) + expect(plan?.launchCommand).toContain('test -f "$ORCA_OMP_FRESH_CONFIG"') + }) +}) diff --git a/src/shared/omp-fresh-launch.ts b/src/shared/omp-fresh-launch.ts new file mode 100644 index 00000000000..b796ec2f7d2 --- /dev/null +++ b/src/shared/omp-fresh-launch.ts @@ -0,0 +1,83 @@ +import { OMP_DRAFT_LAUNCH_PREFIX } from './omp-draft-launch' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' + +export const ORCA_OMP_FRESH_CONFIG_ENV = 'ORCA_OMP_FRESH_CONFIG' +export const OMP_FRESH_CONFIG_FILENAME = 'fresh-session.yml' +export const OMP_FRESH_CONFIG_SOURCE = 'autoResume: false\n' + +// Unknown flags may consume values or select a subcommand; leave those commands intact. +const VALUE_FLAGS = new Set([ + '--model', + '--provider', + '--thinking', + '--config', + '--profile', + '--extension', + '-e', + '--system-prompt', + '--append-system-prompt', + '--tools', + '--skill', + '--theme', + '--api-key' +]) +const SWITCH_FLAGS = new Set(['--no-extensions', '--no-skills', '--no-prompt-templates']) + +/** Apply fresh intent to one launch command, never the saved resume configuration. */ +export function withFreshOmpLaunch(command: string, shell: AgentStartupShell, suffix = ''): string { + const parsed = tokenizeStartupCommand(command, shell) + if (!parsed.ok || parsed.spans.some((span) => span.divergesFromShell)) { + return command + suffix + } + const executable = parsed.tokens[0]?.split(/[\\/]/).at(-1)?.toLowerCase() + if (!['omp', 'omp.exe', 'omp.cmd', 'omp.bat', 'omp.sh', 'omp.js'].includes(executable ?? '')) { + return command + suffix + } + let index = parsed.tokens[1] === 'launch' ? 2 : 1 + for (; index < parsed.tokens.length; index++) { + const token = parsed.tokens[index] + const equals = token.indexOf('=') + const flag = equals === -1 ? token : token.slice(0, equals) + if (VALUE_FLAGS.has(flag)) { + if (equals === -1 && ++index >= parsed.tokens.length) { + return command + suffix + } + } else if (!SWITCH_FLAGS.has(token)) { + return command + suffix + } + } + const path = + shell === 'cmd' + ? `"%${ORCA_OMP_FRESH_CONFIG_ENV}%"` + : shell === 'powershell' + ? `"$env:${ORCA_OMP_FRESH_CONFIG_ENV}"` + : `"$${ORCA_OMP_FRESH_CONFIG_ENV}"` + const launch = `${command} --config ${path}${suffix}` + const error = + 'Orca: fresh OMP settings are unavailable on this host. Restart the terminal after updating Orca.' + if (shell === 'powershell') { + return `if (${path} -and (Test-Path -LiteralPath ${path} -PathType Leaf)) { ${launch} } else { $global:LASTEXITCODE = 1; Write-Error '${error}' }` + } + if (shell === 'cmd') { + return `if exist ${path} (if not exist "${path.slice(1, -1)}\\*" (${launch}) else (echo ${error} 1>&2 & verify invalid 2>nul)) else (echo ${error} 1>&2 & verify invalid 2>nul)` + } + const available = `printenv ${ORCA_OMP_FRESH_CONFIG_ENV} >/dev/null && test -f ${path}` + // && is supported by bash, zsh, and fish; invoking directly preserves shell aliases/functions. + return `${available} || printf '%s\\n' '${error}' >&2; ${available} && ${launch}` +} + +/** Recognize only the host-settings guards generated by this module. */ +export function isFreshOmpLaunchCommand(command: string | undefined): boolean { + if (command?.startsWith(OMP_DRAFT_LAUNCH_PREFIX)) { + return true + } + const guarded = command?.startsWith('( ') ? command.slice(2) : command + return Boolean( + guarded && + [ + `printenv ${ORCA_OMP_FRESH_CONFIG_ENV} >/dev/null && test -f "$${ORCA_OMP_FRESH_CONFIG_ENV}"`, + `if ("$env:${ORCA_OMP_FRESH_CONFIG_ENV}" -and (Test-Path -LiteralPath "$env:${ORCA_OMP_FRESH_CONFIG_ENV}" -PathType Leaf))`, + `if exist "%${ORCA_OMP_FRESH_CONFIG_ENV}%" (if not exist "%${ORCA_OMP_FRESH_CONFIG_ENV}%\\*"` + ].some((prefix) => guarded.startsWith(prefix)) + ) +} diff --git a/src/shared/pi-agent-kind.ts b/src/shared/pi-agent-kind.ts index b3ba1006d8d..ec645fc4d67 100644 --- a/src/shared/pi-agent-kind.ts +++ b/src/shared/pi-agent-kind.ts @@ -1,3 +1,4 @@ +import { isFreshOmpLaunchCommand } from './omp-fresh-launch' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { getCommandTokenPathBasename, getFirstCommandToken } from './command-token-scanner' @@ -49,6 +50,9 @@ const PRIME_AGENT_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG['prime-agent' export function detectExplicitPiAgentKindFromCommand( command: string | undefined ): PiAgentKind | null { + if (isFreshOmpLaunchCommand(command)) { + return 'omp' + } const binary = getLaunchBinary(command ?? '') if (binary === OMP_LAUNCH_BINARY) { return 'omp' diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 97ee45ae425..d30e0bdbb5f 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -666,9 +666,7 @@ describe('tui agent startup plans', () => { expect(plan).not.toBeNull() expect(plan?.env).toEqual({ ORCA_OMP_PREFILL: 'fix the omp regression' }) expect(plan?.expectedProcess).toBe('omp') - expect(plan?.launchCommand).toBe( - `omp; command test -n "$fish_pid" && set --erase -g ORCA_OMP_PREFILL; command test -z "$fish_pid" && unset ORCA_OMP_PREFILL; true` - ) + expect(plan?.launchConfig.agentCommand).toBe('omp') }) it('returns null for oversized Windows flag drafts so callers paste after ready', () => { diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index fba16776378..57c6e49ac48 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,3 +1,5 @@ +import { withFreshOmpLaunch, isFreshOmpLaunchCommand } from './omp-fresh-launch' +import { withOmpDraftCleanup } from './omp-draft-launch' import { isShellProcess } from './agent-detection' import type { SleepingAgentLaunchConfig } from './agent-session-resume' import { @@ -70,6 +72,8 @@ export function buildAgentStartupPlan(args: { if (!baseCommand.ok) { return null } + const launchCommand = + agent === 'omp' ? withFreshOmpLaunch(baseCommand.command, shell) : baseCommand.command const launchConfig = buildSleepingAgentLaunchConfig({ ...args, // Why: picker flags are a one-time launch choice; a resumed provider @@ -83,7 +87,7 @@ export function buildAgentStartupPlan(args: { } return { agent, - launchCommand: baseCommand.command, + launchCommand, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -98,7 +102,10 @@ export function buildAgentStartupPlan(args: { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' return { agent, - launchCommand: `${baseCommand.command}${promptSeparator} ${quotedPrompt}`, + launchCommand: + agent === 'omp' + ? withFreshOmpLaunch(baseCommand.command, shell, `${promptSeparator} ${quotedPrompt}`) + : `${launchCommand}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -111,7 +118,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-prompt') { return { agent, - launchCommand: `${baseCommand.command} --prompt ${quotedPrompt}`, + launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -149,7 +156,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-prompt-interactive') { return { agent, - launchCommand: `${baseCommand.command} --prompt-interactive ${quotedPrompt}`, + launchCommand: `${launchCommand} --prompt-interactive ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -161,7 +168,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-interactive') { return { agent, - launchCommand: `${baseCommand.command} -i ${quotedPrompt}`, + launchCommand: `${launchCommand} -i ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -172,7 +179,7 @@ export function buildAgentStartupPlan(args: { return { agent, - launchCommand: baseCommand.command, + launchCommand, expectedProcess: config.expectedProcess, followupPrompt: trimmedPrompt, launchConfig, @@ -222,6 +229,8 @@ export function buildAgentDraftLaunchPlan(args: { if (!baseCommand.ok) { return null } + const launchCommand = + agent === 'omp' ? withFreshOmpLaunch(baseCommand.command, shell) : baseCommand.command const launchConfig = buildSleepingAgentLaunchConfig({ ...args, // Why: see the new-session path above — resume must not replay picker flags. @@ -232,7 +241,7 @@ export function buildAgentDraftLaunchPlan(args: { const quoted = quoteStartupArg(trimmed, shell) plan = { agent, - launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`, + launchCommand: `${launchCommand} ${config.draftPromptFlag} ${quoted}`, expectedProcess: config.expectedProcess, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), @@ -244,7 +253,10 @@ export function buildAgentDraftLaunchPlan(args: { const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell) plan = { agent, - launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`, + launchCommand: + agent === 'omp' && isFreshOmpLaunchCommand(launchCommand) + ? withOmpDraftCleanup(launchCommand, shell) + : `${launchCommand}${commandSeparator(shell)}${clearVar}`, expectedProcess: config.expectedProcess, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), diff --git a/tests/tools/omp-fresh-launch-windows-smoke.ts b/tests/tools/omp-fresh-launch-windows-smoke.ts new file mode 100644 index 00000000000..b3e071dcc91 --- /dev/null +++ b/tests/tools/omp-fresh-launch-windows-smoke.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import assert from 'node:assert/strict' +import { runProcess } from '../../src/shared/child-process/run-process' +import { withFreshOmpLaunch } from '../../src/shared/omp-fresh-launch' +if (process.platform !== 'win32') { + throw new Error('Run this smoke on a Windows host') +} +const root = mkdtempSync(join(tmpdir(), 'orca-fresh-windows-')) +const config = join(root, 'fresh settings.yml') +const capture = join(root, 'calls') +const results = [] +try { + writeFileSync(join(root, 'omp.cmd'), '@echo off\r\necho %*>>"%CAPTURE%"\r\nexit /b 17\r\n') + for (const shell of ['cmd', 'powershell'] as const) { + for (const state of ['present', 'missing', 'unset', 'directory']) { + rmSync(config, { force: true, recursive: true }) + rmSync(capture, { force: true }) + if (state === 'present') { + writeFileSync(config, 'autoResume: false\n') + } + if (state === 'directory') { + mkdirSync(config) + } + const env: NodeJS.ProcessEnv = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_OMP_FRESH_CONFIG: config, + CAPTURE: capture + } + if (state === 'unset') { + delete env.ORCA_OMP_FRESH_CONFIG + } + const command = withFreshOmpLaunch('omp', shell, ' "task with spaces"') + const runner = join(root, 'run.cmd') + writeFileSync(runner, `@echo off\r\n${command}\r\n`) + const result = await runProcess({ + program: shell === 'cmd' ? runner : 'powershell.exe', + args: + shell === 'cmd' + ? [] + : [ + '-NoProfile', + '-NonInteractive', + '-Command', + `function omp { $args | ConvertTo-Json -Compress | Add-Content -LiteralPath $env:CAPTURE; $global:LASTEXITCODE = 17 }; ${ + command + }; exit $global:LASTEXITCODE` + ], + cwd: root, + env + }) + assert.equal( + result.code, + state === 'present' ? 17 : 1, + JSON.stringify({ shell, state, ...result }) + ) + if (state === 'present') { + const calls = readFileSync(capture, 'utf8').trim().split(/\r?\n/) + assert.equal(calls.length, 1) + assert.ok(calls[0].includes('--config')) + assert.ok(calls[0].includes('task with spaces')) + } else { + assert.ok( + result.stderr.includes('fresh OMP settings are unavailable'), + JSON.stringify(result) + ) + assert.throws(() => readFileSync(capture)) + } + results.push({ shell, state, code: result.code, passed: true }) + } + } + console.log(JSON.stringify(results)) +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/tests/tools/omp-fresh-session-runtime-smoke.mjs b/tests/tools/omp-fresh-session-runtime-smoke.mjs new file mode 100644 index 00000000000..84ca8f58d68 --- /dev/null +++ b/tests/tools/omp-fresh-session-runtime-smoke.mjs @@ -0,0 +1,75 @@ +// Bun, with a read-only OMP source checkout as argv[2]. No model requests. +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { OMP_FRESH_CONFIG_SOURCE } from '../../src/shared/omp-fresh-launch.ts' + +assert.ok(process.argv[2], 'Pass a read-only OMP checkout path') +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-fresh-proof-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +process.env.XDG_CONFIG_HOME = join(scratch, 'xdg-config') +process.env.XDG_DATA_HOME = join(scratch, 'xdg-data') +process.env.XDG_STATE_HOME = join(scratch, 'xdg-state') +process.env.OMP_CODING_AGENT_DIR = join(scratch, 'agent') +delete process.env.PI_CONFIG_FILES +const source = (path) => + pathToFileURL(join(resolve(process.argv[2]), 'packages/coding-agent/src', path)).href +const managers = [] +try { + await mkdir(process.env.HOME, { recursive: true }) + const { SessionManager } = await import(source('session/session-manager.ts')) + const { Settings, resetSettingsForTest } = await import(source('config/settings.ts')) + const { createSessionManager } = await import(source('main.ts')) + const cwd = join(scratch, 'project') + await mkdir(cwd) + const previous = SessionManager.create(cwd) + managers.push(previous) + previous.appendMessage({ role: 'user', content: 'previous task', timestamp: Date.now() }) + await previous.ensureOnDisk() + await previous.flush() + const config = join(scratch, 'fresh.yml') + const userConfig = join(scratch, 'user.yml') + await writeFile(config, OMP_FRESH_CONFIG_SOURCE) + await writeFile(userConfig, 'autoResume: true\n') + const settings = await Settings.init({ cwd, configFiles: [userConfig] }) + const resumed = await createSessionManager({}, cwd, settings) + managers.push(resumed) + assert.equal(resumed.getSessionId(), previous.getSessionId()) + resetSettingsForTest() + const freshSettings = await Settings.init({ cwd, configFiles: [userConfig, config] }) + assert.equal(freshSettings.get('autoResume'), false) + assert.equal(settings.get('autoResume'), true) + const defaultSelection = await createSessionManager({}, cwd, freshSettings) + assert.equal(defaultSelection, undefined, 'SDK creates a fresh session after undefined selection') + const fresh = SessionManager.create(cwd) + managers.push(fresh) + assert.notEqual(fresh.getSessionId(), previous.getSessionId()) + assert.equal(fresh.getSessionDir(), previous.getSessionDir()) + const explicit = await createSessionManager( + { resume: previous.getSessionFile() }, + cwd, + freshSettings + ) + managers.push(explicit) + assert.equal(explicit.getSessionId(), previous.getSessionId()) + console.log( + JSON.stringify({ + platform: process.platform, + autoResumeReproduced: true, + freshSelected: true, + storageDirectoryPreserved: true, + explicitResumePreserved: true, + modelCalls: 0, + scope: + 'Actual OMP Settings overlays, persistent SessionManager and createSessionManager; no rendered UI' + }) + ) +} finally { + for (const manager of managers) { + await manager?.close() + } + await rm(scratch, { recursive: true, force: true }) +}