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.ts b/src/main/pi/titlebar-extension-service.ts index 1d642b0cf1a..64a021603f7 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,8 +1,4 @@ -import { - OMP_FRESH_CONFIG_FILENAME, - OMP_FRESH_CONFIG_SOURCE, - ORCA_OMP_FRESH_CONFIG_ENV -} from '../../shared/omp-fresh-launch' +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' @@ -179,26 +175,22 @@ 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 freshConfigEnv: Record = {} - if (kind === 'omp') { - const configDir = join( - getAppEnvironment().getPath('userData'), - OMP_MANAGED_STATUS_EXTENSION_DIR - ) - mkdirSync(configDir, { recursive: true }) - const configPath = join(configDir, OMP_FRESH_CONFIG_FILENAME) - writeFileSync(configPath, OMP_FRESH_CONFIG_SOURCE) - freshConfigEnv[ORCA_OMP_FRESH_CONFIG_ENV] = configPath - } + 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') { 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..bdd0f9687a6 --- /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 = 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/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..0fc76aa6ba4 --- /dev/null +++ b/src/shared/omp-fresh-launch-shell.test.ts @@ -0,0 +1,74 @@ +import { existsSync } from 'node:fs' +import { delimiter } from 'node:path' +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { 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 index eda9e07026b..49bc83a996f 100644 --- a/src/shared/omp-fresh-launch.test.ts +++ b/src/shared/omp-fresh-launch.test.ts @@ -6,7 +6,7 @@ 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')).toBe( + expect(withFreshOmpLaunch(command, 'posix')).toContain( `${command} --config "$ORCA_OMP_FRESH_CONFIG"` ) } @@ -29,13 +29,13 @@ describe('OMP fresh launch intent', () => { 'echo omp', 'omp && echo hi' ])('preserves %s', (command) => { - expect(withFreshOmpLaunch(command, 'posix')).toBe(command) + expect(withFreshOmpLaunch(command, 'posix')).toContain(command) }) it('quotes the host config path for each Windows shell', () => { - expect(withFreshOmpLaunch('omp', 'powershell')).toBe( + expect(withFreshOmpLaunch('omp', 'powershell')).toContain( 'omp --config "$env:ORCA_OMP_FRESH_CONFIG"' ) - expect(withFreshOmpLaunch('omp', 'cmd')).toBe('omp --config "%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({ @@ -48,7 +48,7 @@ describe('OMP fresh launch intent', () => { expect(JSON.stringify(plan?.launchConfig)).not.toContain('ORCA_OMP_FRESH_CONFIG') expect(plan?.env).toBeUndefined() }) - it('does not require a new environment field from an older SSH relay', () => { + it('requires host-owned configuration for fresh SSH launches', () => { const plan = buildAgentStartupPlan({ agent: 'omp', prompt: 'new task', @@ -56,6 +56,6 @@ describe('OMP fresh launch intent', () => { platform: 'linux', isRemote: true }) - expect(plan?.launchCommand).not.toContain('ORCA_OMP_FRESH_CONFIG') + 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 index 2cbddb969b4..11d9bf0d3e0 100644 --- a/src/shared/omp-fresh-launch.ts +++ b/src/shared/omp-fresh-launch.ts @@ -23,14 +23,14 @@ const VALUE_FLAGS = new Set([ 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): string { +export function withFreshOmpLaunch(command: string, shell: AgentStartupShell, suffix = ''): string { const parsed = tokenizeStartupCommand(command, shell) if (!parsed.ok) { - return command + 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 + return command + suffix } let index = parsed.tokens[1] === 'launch' ? 2 : 1 for (; index < parsed.tokens.length; index++) { @@ -39,10 +39,10 @@ export function withFreshOmpLaunch(command: string, shell: AgentStartupShell): s const flag = equals === -1 ? token : token.slice(0, equals) if (VALUE_FLAGS.has(flag)) { if (equals === -1 && ++index >= parsed.tokens.length) { - return command + return command + suffix } } else if (!SWITCH_FLAGS.has(token)) { - return command + return command + suffix } } const path = @@ -51,5 +51,28 @@ export function withFreshOmpLaunch(command: string, shell: AgentStartupShell): s : shell === 'powershell' ? `"$env:${ORCA_OMP_FRESH_CONFIG_ENV}"` : `"$${ORCA_OMP_FRESH_CONFIG_ENV}"` - return `${command} --config ${path}` + 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 { + return Boolean( + command && + [ + `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) => command.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 7ef89709af4..1943567e027 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -666,7 +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( + expect(plan?.launchCommand).toContain( `omp --config "$ORCA_OMP_FRESH_CONFIG"; command test -n "$fish_pid" && set --erase -g ORCA_OMP_PREFILL; command test -z "$fish_pid" && unset ORCA_OMP_PREFILL; true` ) }) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index f2d95d5652a..501b45fe165 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -72,9 +72,7 @@ export function buildAgentStartupPlan(args: { return null } const launchCommand = - agent === 'omp' && !args.isRemote - ? withFreshOmpLaunch(baseCommand.command, shell) - : baseCommand.command + agent === 'omp' ? withFreshOmpLaunch(baseCommand.command, shell) : baseCommand.command const launchConfig = buildSleepingAgentLaunchConfig({ ...args, // Why: picker flags are a one-time launch choice; a resumed provider @@ -103,7 +101,10 @@ export function buildAgentStartupPlan(args: { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' return { agent, - launchCommand: `${launchCommand}${promptSeparator} ${quotedPrompt}`, + launchCommand: + agent === 'omp' + ? withFreshOmpLaunch(baseCommand.command, shell, `${promptSeparator} ${quotedPrompt}`) + : `${launchCommand}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, @@ -228,9 +229,7 @@ export function buildAgentDraftLaunchPlan(args: { return null } const launchCommand = - agent === 'omp' && !args.isRemote - ? withFreshOmpLaunch(baseCommand.command, shell) - : baseCommand.command + 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. 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 }) +}