diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 3a547d35c0e..53c0e43ff88 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -91,7 +91,7 @@ export async function prepareOpenCodePtyLaunch(options: { const last = parsed.ok ? parsed.tokens.length - 1 : -1 if ( parsed.ok && - !isOpenCodeRunCommand(parsed.tokens) && + !isOpenCodeRunCommand(parsed.tokens, shell) && parsed.tokens.filter((token) => token === '--prompt').length === 1 && parsed.tokens[last - 1] === '--prompt' && parsed.tokens[last] === body && diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts index 28dd70cf0bd..50e1be306fa 100644 --- a/src/shared/flag-prompt-startup.ts +++ b/src/shared/flag-prompt-startup.ts @@ -4,7 +4,7 @@ import type { SessionOptionValue } from './native-chat-session-options' import type { TuiAgent } from './tui-agent' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' -import { isOpenCodeRunCommand } from './opencode-headless-command' +import { findOpenCodeRunCommand } from './opencode-headless-command' import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export function appliedSessionOptionProps(values: Record) { @@ -22,13 +22,13 @@ export function buildFlagPromptStartupPlan(args: { agentEnv: Record | null | undefined }): AgentStartupPlan { const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) - const isOpenCodeRun = - (args.agent === 'opencode' || args.agent === 'opencode2') && - parsed.ok && - isOpenCodeRunCommand(parsed.tokens) + const openCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && parsed.ok + ? findOpenCodeRunCommand(parsed.tokens, args.shell) + : null // OpenCode run takes a positional message; --prompt belongs to its TUI. - const promptSuffix = isOpenCodeRun - ? parsed.tokens.includes('--') + const promptSuffix = openCodeRun + ? openCodeRun.messageSeparatorIndex !== null ? ` ${args.quotedPrompt}` : ` -- ${args.quotedPrompt}` : ` --prompt ${args.quotedPrompt}` diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 408895a1dbb..cc240be7f9c 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' import { tokenizeCommandLine } from './agent-command-line-entrypoint' import { isOpenCodeRunCommand } from './opencode-headless-command' @@ -24,3 +25,32 @@ describe('isOpenCodeRunCommand', () => { expect(matches('opencode --log-level run')).toBe(false) }) }) + +describe('wrapped OpenCode run command position', () => { + it.each([ + 'CUSTOM_CONFIG=private opencode --log-level debug run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone' + ])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it('recognizes a PowerShell call operator before a quoted executable', () => { + const parsed = tokenizeStartupCommand( + '& "C:\\Program Files\\opencode\\opencode.exe" run --standalone', + 'powershell' + ) + expect(parsed.ok).toBe(true) + if (!parsed.ok) throw new Error(parsed.error) + expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) + expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) + }) + + it('does not find executable names inside other commands or prompt arguments', () => { + expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) + expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) + expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false) + expect(matches('env -- opencode serve --title run')).toBe(false) + expect(matches('opencode attach http://host/run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 5328f7931fc..858fb965125 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -1,16 +1,82 @@ -// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. -// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, -// whose in-process plugin reports it. Only `--log-level` takes a separate value before the -// subcommand; any other valued option makes the value the first positional, which fails safe. -export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { +import { extractLeadingEnvAssignments } from './command-environment' +import { getCommandTokenPathBasename } from './command-token-scanner' +import type { AgentStartupShell } from './tui-agent-startup-shell' + +const RUN_VALUE_FLAGS = new Set([ + '--log-level', + '--completions', + '--server', + '--session', + '-s', + '--model', + '-m', + '--agent', + '--format', + '--file', + '-f', + '--title' +]) + +function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { + if (shell === 'powershell' && tokens[0] === '&') { + return 1 + } + if (shell !== 'posix') { + return 0 + } + let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length + if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { + index += 1 + if (tokens[index] === '--') { + index += 1 + } + index = tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length + } + return index +} + +export function findOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): { runIndex: number; messageSeparatorIndex: number | null } | null { + const commandPosition = openCodeCommandPosition(tokens, shell) + if (commandPosition > 0) { + const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '') + .toLowerCase() + .replace(/\.(?:exe|cmd)$/, '') + if (binary !== 'opencode' && binary !== 'opencode2') { + return null + } + } + for (let index = commandPosition + 1; index < tokens.length; index += 1) { const token = tokens[index] if (!token.startsWith('-')) { - return token === 'run' + if (token !== 'run') { + return null + } + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) { + const argument = tokens[argumentIndex] + if (argument === '--') { + return { runIndex: index, messageSeparatorIndex: argumentIndex } + } + if (RUN_VALUE_FLAGS.has(argument)) { + argumentIndex += 1 + } + } + return { runIndex: index, messageSeparatorIndex: null } } + // Other valued global options fail closed at their first positional value. if (token === '--log-level') { index += 1 } } - return false + return null +} + +// OpenCode run's process lifetime is its turn; v1 still reports through its plugin. +export function isOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): boolean { + return findOpenCodeRunCommand(tokens, shell) !== null } diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index d438028ba96..e82b46f8e77 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -105,3 +105,55 @@ describe('native OpenCode startup submission intent', () => { ).toBeUndefined() }) }) + +describe('wrapped OpenCode run startup', () => { + it.each(['opencode', 'opencode2'] as const)( + 'keeps %s run flags and positional task behind POSIX prefixes', + (agent) => { + for (const command of [ + 'CUSTOM_CONFIG=private opencode run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone --', + 'env CUSTOM_CONFIG=private opencode run --title "--"' + ]) { + const plan = buildAgentStartupPlan({ + agent, + prompt: '--literal task', + cmdOverrides: { [agent]: command }, + platform: 'linux', + shell: 'posix', + isRemote: true, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe( + command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` + ) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'preserves %s PowerShell call syntax and its run separator', + (agent) => { + for (const separator of ['', ' --']) { + const command = + '& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone' + + separator + const plan = buildAgentStartupPlan({ + agent, + prompt: "--task's é", + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell: 'powershell', + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe(command + (separator ? ' ' : ' -- ') + "'--task''s é'") + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts index 1dbe9c2719d..e3b1c462ffa 100644 --- a/src/shared/opencode-startup-prompt.ts +++ b/src/shared/opencode-startup-prompt.ts @@ -21,7 +21,7 @@ export function openCodeStartupPromptEnv( if ( (agent !== 'opencode' && agent !== 'opencode2') || !parsed.ok || - isOpenCodeRunCommand(parsed.tokens) + isOpenCodeRunCommand(parsed.tokens, shell) ) { return env ? { env: { ...env } } : {} }