diff --git a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts index 6e6354f385b..5c54ebdccf5 100644 --- a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts +++ b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts @@ -51,7 +51,7 @@ describe('a daemon WSL session with a launch file', () => { it('writes the file into the distro and types its Linux path', async () => { await create({ distro: 'Ubuntu', windowsPath: windowsSide, linuxPath: '/home/ada/.cache/orca' }) const command = spawn.mock.calls[0]?.[0].command ?? '' - const linuxPath = /\/home\/ada\/\.cache\/orca\/orca-launch-file-\w+\/task-context\.md/.exec( + const linuxPath = /\/home\/ada\/\.cache\/orca\/orca-launch-file-\d+-\w+\/task-context\.md/.exec( command )?.[0] expect(command).toBe(`claude '${buildLaunchFilePointer(linuxPath ?? 'missing')}'`) diff --git a/src/main/ipc/pty/ipc/launched-agent-foreground.ts b/src/main/ipc/pty/ipc/launched-agent-foreground.ts new file mode 100644 index 00000000000..0d8246cef57 --- /dev/null +++ b/src/main/ipc/pty/ipc/launched-agent-foreground.ts @@ -0,0 +1,19 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { LaunchedAgentForeground } from '../../../runtime/launched-agent-foreground' + +/** The renderer's launch-prompt receipt asks the question #24257's crash guard asks before a paste. */ +export function installLaunchedAgentForegroundIpcHandler( + runtime: OrcaRuntimeService | undefined +): void { + getPtyIpc().handle( + 'pty:readLaunchedAgentForeground', + async (_event, args: { id: string; agent: string }): Promise => { + if (!runtime || typeof args?.id !== 'string' || !isTuiAgent(args.agent)) { + return 'unknown' + } + return runtime.readLaunchedAgentForeground(args.id, args.agent) + } + ) +} diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index e35b40ee637..dcd8a89db73 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -2,7 +2,10 @@ import { getPtyIpc } from '../../pty-host-bindings' import { runPtyIpcSpawn } from './spawn-run' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file' -import { recordTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals' +import { + noteTerminalPaneSpawn, + recordTerminalLaunchRefusal +} from '../../../runtime/terminal-launch-refusals' export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { const ipcMain = getPtyIpc() @@ -13,6 +16,8 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } + // Why: a create waiting on this tab's handle keeps waiting while the spawn still runs. + const settleSpawn = args.tabId ? noteTerminalPaneSpawn(args.tabId) : () => {} try { return await runPtyIpcSpawn(deps, args) } catch (error) { @@ -22,6 +27,8 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { recordTerminalLaunchRefusal(args.tabId, message) } throw error + } finally { + settleSpawn() } }) } diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9ce1d4082a4..6ca836bb545 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -21,6 +21,7 @@ import { import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' import { installPtyRuntimeController } from './runtime/controller' +import { installLaunchedAgentForegroundIpcHandler } from './ipc/launched-agent-foreground' import { installPtySnapshotIpcHandlers } from './ipc/snapshot' import { assertFolderWorkspacePtyPathUsable as assertFolderWorkspacePtyPathUsableImpl, @@ -119,6 +120,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeHandler('pty:inspectProcess') ipcMain.removeHandler('pty:confirmForegroundProcess') + ipcMain.removeHandler('pty:readLaunchedAgentForeground') ipcMain.removeHandler('pty:isCodexOnSharedServer') ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart') ipcMain.removeHandler('pty:stopCodexSharedServer') @@ -282,6 +284,7 @@ export function registerPtyHandlers( installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) + installLaunchedAgentForegroundIpcHandler(runtime) installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 222f9c4bd38..492a289f10c 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -161,7 +161,7 @@ describe('a terminal create that is handed a launch prompt', () => { startupAgent: 'aider', startupPrompt: 'summarize the diff' }) - ).rejects.toThrow(/does not take a startup prompt/) + ).rejects.toThrow(/takes its prompt only after it starts/) expect(spawn).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/launch-host-agent-in-front.test.ts b/src/main/runtime/launch-host-agent-in-front.test.ts deleted file mode 100644 index 12c7f209ffc..00000000000 --- a/src/main/runtime/launch-host-agent-in-front.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { launchHostProvesAgentInFront } from './launch-host-agent-in-front' - -describe('whether a launch host can prove its agent is in front before a paste', () => { - it.each([ - ['a local macOS host', false, 'darwin', 'darwin', true], - ['a local Linux host', false, 'linux', 'linux', true], - ['a local Windows host', false, 'win32', 'win32', false], - // The pane runs in the distro, but the reads run on the Windows host. - ['a local WSL pane', false, 'linux', 'win32', false], - ['an SSH Linux host from Windows', true, 'linux', 'win32', true], - ['an SSH Windows host', true, 'win32', 'darwin', false] - ] as const)('%s: %s', (_label, isRemote, launchPlatform, hostPlatform, proves) => { - expect(launchHostProvesAgentInFront({ isRemote, launchPlatform, hostPlatform })).toBe(proves) - }) -}) diff --git a/src/main/runtime/launch-host-agent-in-front.ts b/src/main/runtime/launch-host-agent-in-front.ts deleted file mode 100644 index 1badb1bc806..00000000000 --- a/src/main/runtime/launch-host-agent-in-front.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Whether the execution host can prove a launched agent holds its terminal before a paste - * (`launched-agent-foreground`). A Windows host cannot, and a local WSL pane runs on one; an SSH - * host is judged by its own platform. - */ -export function launchHostProvesAgentInFront(args: { - isRemote: boolean - launchPlatform: NodeJS.Platform - hostPlatform?: NodeJS.Platform -}): boolean { - return args.isRemote - ? args.launchPlatform !== 'win32' - : (args.hostPlatform ?? process.platform) !== 'win32' -} diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index f8ef09234db..91dd2658df2 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -213,19 +213,22 @@ describe('agent-session create operation ledger', () => { ) }) - it('refuses a prompt it could only paste, having no paste after ready', async () => { + // Why: main started this agent with the whole prompt on its line; with no paste after ready this + // create must not refuse what main started. + it('keeps a prompt it cannot paste on the agent’s line, as main did', async () => { const runtime = createRuntime({ supportsAgentSessionClaims: () => true, supportsAgentSessionCreateOperations: () => true }) const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) - await expect( - runtime.createAgentSession( - request(operationId(), { agent: 'gemini', prompt: 'x'.repeat(20_000) }) - ) - ).rejects.toThrow(/gemini cannot take this prompt on its command line here/) - expect(createTerminal).not.toHaveBeenCalled() + await runtime.createAgentSession( + request(operationId(), { agent: 'gemini', prompt: 'x'.repeat(20_000) }) + ) + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ command: expect.stringContaining('x'.repeat(20_000)) }) + ) }) it('requests exact client legacy fallback before nested SSH side effects', async () => { diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 4523e483d50..63370145083 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -27,6 +27,7 @@ import { windowsDraftRefusal } from '../../shared/launch-prompt-carry' import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { AgentSessionCreateOperation, @@ -180,7 +181,16 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe throw new Error(refusal ?? 'agent_session_identity_required') } } else { - const planned = planLaunchPrompt({ ...startupArgs, prompt: request.prompt ?? '' }) + const planned = planLaunchPrompt({ + ...startupArgs, + prompt: request.prompt ?? '', + host: thisOrcaLaunchHost({ + launchPlatform: startupArgs.platform, + isRemote: Boolean(workspace.connectionId) + }), + // Why: this create returns before the agent is ready, so nothing pastes after it. + canPasteAfterReady: false + }) if (!planned) { throw new Error('agent_session_identity_required') } diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.test.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.test.ts new file mode 100644 index 00000000000..03be15e6988 --- /dev/null +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file' + +const surface = vi.hoisted(() => { + const handlers = new Map void>() + return { + handlers, + onIpc: (channel: string, handler: (event: unknown, response: unknown) => void) => + handlers.set(channel, handler), + removeIpcListener: vi.fn() + } +}) + +vi.mock('./orca-runtime-create-terminal-dependencies', () => ({ + randomUUID: () => 'request-1', + ownerSurfacing: () => ({}), + getRuntimeDesktopSurface: () => surface +})) + +import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop' + +describe('a desktop terminal create whose pane the host refused', () => { + // Why: the pane never started, so closing its tab is what makes the refusal a launch without + // effects, and lets the phone's replay be told the reason instead of "outcome unknown". + it('closes the tab it opened and reports the refusal', async () => { + const refusal = new Error(describeLaunchFileUnavailable('disk full')) + const webContents = { + send: (_channel: string, request: { requestId: string }) => + surface.handlers.get('terminal:tabCreateReply')?.( + { sender: webContents }, + { requestId: request.requestId, tabId: 'tab-refused' } + ) + } + const closeTerminalTab = vi.fn(async () => {}) + const runtime = { + assertGraphReady: () => {}, + getAuthoritativeWindow: () => ({ webContents }), + resolveTerminalWorkspaceLaunchScope: async () => null, + waitForTerminalHandle: vi.fn(async () => { + throw refusal + }), + notifier: { closeTerminalTab } + } + + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create reads only the members stubbed above. + createDesktopTerminal(runtime as never, undefined, { command: 'claude' }, undefined, null) + ).rejects.toBe(refusal) + expect(closeTerminalTab).toHaveBeenCalledWith('tab-refused', { force: true }) + }) + + it('leaves the tab of any other failure', async () => { + const webContents = { + send: (_channel: string, request: { requestId: string }) => + surface.handlers.get('terminal:tabCreateReply')?.( + { sender: webContents }, + { requestId: request.requestId, tabId: 'tab-slow' } + ) + } + const closeTerminalTab = vi.fn(async () => {}) + const runtime = { + assertGraphReady: () => {}, + getAuthoritativeWindow: () => ({ webContents }), + resolveTerminalWorkspaceLaunchScope: async () => null, + waitForTerminalHandle: vi.fn(async () => { + throw new Error('Timed out waiting for terminal handle after creation') + }), + notifier: { closeTerminalTab } + } + + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create reads only the members stubbed above. + createDesktopTerminal(runtime as never, undefined, { command: 'claude' }, undefined, null) + ).rejects.toThrow('Timed out') + expect(closeTerminalTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 1bcf2d346bd..f5bb03439b1 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -2,6 +2,7 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal' import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' +import { isLaunchFileRefusal } from '../../shared/launch-prompt-file' export async function createDesktopTerminal( runtime: OrcaRuntimeWithCreateTerminal, @@ -78,7 +79,18 @@ export async function createDesktopTerminal( ...dependencies.ownerSurfacing(opts.surfaceOwner !== false) }) }) - const handle = await runtime.waitForTerminalHandle(reply.tabId) + let handle: string + try { + handle = await runtime.waitForTerminalHandle(reply.tabId) + } catch (error) { + if (isLaunchFileRefusal(error)) { + // Why: the refused pane never started; closing its tab leaves the launch without effects. + await Promise.resolve( + runtime.notifier?.closeTerminalTab?.(reply.tabId, { force: true }) + ).catch((closeError: unknown) => console.error('Could not close a refused tab', closeError)) + } + throw error + } return { handle, tabId: reply.tabId, diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 26ff9f9d0f9..a619bbb6ee9 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -6,6 +6,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' @@ -48,16 +49,21 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } + // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. + const launchPlatform = this.getAgentLaunchPlatformForWorkspace(workspace) + const isRemote = Boolean(workspace.connectionId) const planned = planLaunchPrompt({ ...resolveAgentStartupPlanInputs({ agent: opts.agent, settings, - // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. - platform: this.getAgentLaunchPlatformForWorkspace(workspace), + platform: launchPlatform, // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. - isRemote: Boolean(workspace.connectionId) + isRemote }), - prompt: opts.agentPrompt ?? '' + prompt: opts.agentPrompt ?? '', + host: thisOrcaLaunchHost({ launchPlatform, isRemote }), + // Why: a quick command has no paste after ready. + canPasteAfterReady: false }) if (!planned) { throw new Error(`Could not build launch command for ${opts.agent}.`) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index c5b3fee7854..dc9ef38d813 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -19,9 +19,9 @@ import { terminalShellOverrideRefusal } from './terminal-shell-override-host-sup import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' -import { agentPromptRidesLaunchCommand, planLaunchPrompt } from '../../shared/tui-agent-startup' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' -import { launchHostProvesAgentInFront } from './launch-host-agent-in-front' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -256,11 +256,6 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return opts } - // A prompt this launch command cannot carry has nowhere to go from here — the create returns - // options, not a live PTY — so refuse rather than spawn the agent and drop the text. - if (opts.startupPrompt && !agentPromptRidesLaunchCommand(agent)) { - throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) - } const planInputs = resolveAgentStartupPlanInputs({ agent, settings, @@ -276,7 +271,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ...planInputs, prompt: opts.startupPrompt ?? '', ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), - hostProvesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform }) + host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }), + canPasteAfterReady: opts.onStartupPromptCarry !== undefined }) if (!planned) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index d540e6d497c..735b2cde640 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -2,10 +2,17 @@ import { OrcaRuntimeWithWaitForMobileTerminalSurface } from './orca-runtime-wait-for-mobile-terminal-surface' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { parsePaneKey } from '../../shared/stable-pane-id' -import { onTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' +import { + onTerminalLaunchRefusal, + takeTerminalLaunchRefusal, + terminalPaneSpawnInFlight +} from './terminal-launch-refusals' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types' +const TERMINAL_HANDLE_RECHECK_MS = 500 +const TERMINAL_HANDLE_SPAWN_IN_FLIGHT_CAP_MS = 60_000 + export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals extends OrcaRuntimeWithWaitForMobileTerminalSurface { protected restoreLivePairedRendererSessionOwnedMobileTerminals( worktreeId: string | null, @@ -146,10 +153,21 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals this.graphSyncCallbacks.splice(idx, 1) } } - const timer = setTimeout(() => { + const startedAt = Date.now() + const onTimeout = (): void => { + // Why: a spawn still running (a cold WSL probe, then the spawn) answers soon; only a tab + // with nothing in flight has timed out. Capped so a wedged spawn still ends the wait. + if ( + terminalPaneSpawnInFlight(tabId) && + Date.now() - startedAt < TERMINAL_HANDLE_SPAWN_IN_FLIGHT_CAP_MS + ) { + timer = setTimeout(onTimeout, TERMINAL_HANDLE_RECHECK_MS) + return + } stop() reject(new Error('Timed out waiting for terminal handle after creation')) - }, timeoutMs) + } + let timer = setTimeout(onTimeout, timeoutMs) // Why: a host that refused the spawn's launch file never registers a handle. const refuse = (message: string): void => { stop() diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts index 28c9cc0dd2e..5545ce19b79 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts @@ -38,6 +38,32 @@ describe('OrcaRuntimeService', () => { expect(spawn).not.toHaveBeenCalled() }) + // Why: main started this agent with the prompt on its line; a quick command has no paste after + // ready, so it keeps the line rather than refuse what main started. + it('starts an agent with a prompt it cannot paste on its line, as main did', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-long-prompt' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'gemini', + agentPrompt: 'g'.repeat(20_000) + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ command: expect.stringContaining('g'.repeat(20_000)) }) + ) + }) + it('uses portable Unix quoting for mobile agent launch commands in WSL project runtimes', async () => { await withPlatform('win32', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.test.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.test.ts new file mode 100644 index 00000000000..d3b2c9751db --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { launchFailureWithoutEffectsCode } from './agent-launch-failure-code' +import { trackTerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' +import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' + +describe('a launch that failed without effects', () => { + // Why: every host refuses a launch file before it spawns anything, so the phone's replay must + // get the reason, not "outcome unknown", even after the spawn request left this process. + it('includes a launch-file refusal for an existing workspace, as it arrives from any host', () => { + const dispatched = trackTerminalSpawnDispatch() + dispatched.onPtySpawnDispatched() + const refusal = new Error(describeLaunchFileUnavailable("the WSL distro's home is gone")) + expect(launchFailureWithoutEffectsCode(refusal, 'existing', dispatched)).toBe( + 'launch_file_unavailable' + ) + }) + + it('leaves it out for a created workspace, which exists whatever the agent did', () => { + const refusal = new Error(describeLaunchFileUnavailable('disk full')) + expect( + launchFailureWithoutEffectsCode(refusal, 'create-worktree', trackTerminalSpawnDispatch()) + ).toBeNull() + }) + + it('leaves out any other failure after the spawn request left', () => { + const dispatched = trackTerminalSpawnDispatch() + dispatched.onPtySpawnDispatched() + expect( + launchFailureWithoutEffectsCode(new Error('spawn ENOENT'), 'existing', dispatched) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts index eac21c7c00e..cba9f6045ae 100644 --- a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -12,6 +12,10 @@ import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../../shared/agent-launch-session-already-exists' import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' +import { + isLaunchFileRefusal, + LAUNCH_FILE_UNAVAILABLE_CODE +} from '../../../../shared/launch-prompt-file' /** Long enough for every code this path raises, with room for one a later guard adds. */ const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 @@ -53,5 +57,11 @@ export function launchFailureWithoutEffectsCode( if (terminalSpawn.failedBeforeDispatch(error) && targetKind === 'existing') { return agentLaunchFailureCode(error) } + // Why without effects: every host refuses before it spawns anything, and a desktop window closes + // the refused pane's tab before the error reaches here (`createDesktopTerminal`). The code, not + // the sentence: the sentence carries a reason long enough to lose its code to the bound. + if (isLaunchFileRefusal(error) && targetKind === 'existing') { + return LAUNCH_FILE_UNAVAILABLE_CODE + } return null } diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 6e5d234536f..447daa30d54 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -15,7 +15,7 @@ import { type AgentStartupPlan } from '../../shared/tui-agent-startup' import type { LaunchFile } from '../../shared/launch-prompt-file' -import { launchHostProvesAgentInFront } from './launch-host-agent-in-front' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -158,10 +158,12 @@ export function buildWorktreeStartupForAgent( const planned = planLaunchPrompt({ ...planInputs, prompt: environment.prompt ?? '', - hostProvesAgentInFront: launchHostProvesAgentInFront({ + host: thisOrcaLaunchHost({ isRemote: repoIsRemote(repo), launchPlatform: environment.getLaunchPlatform() - }) + }), + // A caller that cannot paste takes a follow-up and sends it once the agent is ready. + canPasteAfterReady: true }) if (!planned) { throw new Error(`Could not build launch command for ${agent}.`) diff --git a/src/main/runtime/terminal-launch-refusals.test.ts b/src/main/runtime/terminal-launch-refusals.test.ts index 07c10e96e7b..5ad04e356d2 100644 --- a/src/main/runtime/terminal-launch-refusals.test.ts +++ b/src/main/runtime/terminal-launch-refusals.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' -import { recordTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' +import { + noteTerminalPaneSpawn, + recordTerminalLaunchRefusal, + takeTerminalLaunchRefusal +} from './terminal-launch-refusals' import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file' vi.mock('electron', () => ({ @@ -10,12 +14,16 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function waitForHandle(runtime: OrcaRuntimeService, tabId: string): Promise { +function waitForHandle( + runtime: OrcaRuntimeService, + tabId: string, + timeoutMs = 5_000 +): Promise { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait under test is a protected member of the runtime; the assertion names only that method. const internal = runtime as unknown as { waitForTerminalHandle: (tabId: string, timeoutMs?: number) => Promise } - return internal.waitForTerminalHandle(tabId, 5_000) + return internal.waitForTerminalHandle(tabId, timeoutMs) } describe('a pane spawn the host refused for its launch file', () => { @@ -43,4 +51,25 @@ describe('a pane spawn the host refused for its launch file', () => { recordTerminalLaunchRefusal('tab-early', refusal) await expect(waitForHandle(runtime, 'tab-early')).rejects.toThrow(refusal) }) + + // Why: a cold WSL probe plus the spawn can outlast the create's wait; the agent then starts + // anyway, so a timeout there would be false. + it('keeps waiting past its budget while that tab’s spawn still runs, then times out', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService() + const settleSpawn = noteTerminalPaneSpawn('tab-cold-wsl') + let outcome: string | null = null + waitForHandle(runtime, 'tab-cold-wsl', 100).catch((error: Error) => { + outcome = error.message + }) + await vi.advanceTimersByTimeAsync(5_000) + expect(outcome).toBeNull() + settleSpawn() + await vi.advanceTimersByTimeAsync(1_000) + expect(outcome).toMatch(/Timed out waiting for terminal handle/) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/runtime/terminal-launch-refusals.ts b/src/main/runtime/terminal-launch-refusals.ts index 9ec35d90534..c4419fef865 100644 --- a/src/main/runtime/terminal-launch-refusals.ts +++ b/src/main/runtime/terminal-launch-refusals.ts @@ -8,6 +8,31 @@ const MAX_REFUSALS = 64 const refusals = new Map() const listeners = new Set<(tabId: string, message: string) => void>() +/** Pane spawns still running per tab; each `noteTerminalPaneSpawn` settles in a `finally`. */ +const spawnsInFlight = new Map() + +/** Marks a tab's pane spawn as running until the returned settle runs. */ +export function noteTerminalPaneSpawn(tabId: string): () => void { + spawnsInFlight.set(tabId, (spawnsInFlight.get(tabId) ?? 0) + 1) + let settled = false + return () => { + if (settled) { + return + } + settled = true + const remaining = (spawnsInFlight.get(tabId) ?? 1) - 1 + if (remaining > 0) { + spawnsInFlight.set(tabId, remaining) + } else { + spawnsInFlight.delete(tabId) + } + } +} + +/** Whether a pane spawn for the tab is still running, such as one probing a cold WSL distro. */ +export function terminalPaneSpawnInFlight(tabId: string): boolean { + return spawnsInFlight.has(tabId) +} export function recordTerminalLaunchRefusal(tabId: string, message: string): void { const now = Date.now() diff --git a/src/main/runtime/this-orca-launch-host.ts b/src/main/runtime/this-orca-launch-host.ts new file mode 100644 index 00000000000..3a2d28f3f99 --- /dev/null +++ b/src/main/runtime/this-orca-launch-host.ts @@ -0,0 +1,9 @@ +import { describeLaunchHost, type LaunchHost } from '../../shared/launch-host' + +/** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */ +export function thisOrcaLaunchHost(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean +}): LaunchHost { + return describeLaunchHost({ ...args, hostPlatform: process.platform, paired: false }) +} diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 4bc489eb62e..c854cd3bd64 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -128,6 +128,8 @@ export type PtyApi = { } ) => Promise confirmForegroundProcess: (id: string) => Promise + /** What holds a launched agent's terminal, read fresh on its execution host (#24257's guard). */ + readLaunchedAgentForeground: (id: string, agent: string) => Promise<'agent' | 'shell' | 'unknown'> /** Local panes only; false for any other pane. */ isCodexOnSharedServer: (id: string) => Promise /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index ec5b232acb5..22c8cf8bbf9 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -16,6 +16,11 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:inspectProcess', { id, ...options }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), + readLaunchedAgentForeground: ( + id: string, + agent: string + ): Promise<'agent' | 'shell' | 'unknown'> => + ipcRenderer.invoke('pty:readLaunchedAgentForeground', { id, agent }), isCodexOnSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }), disableCodexSharedServerAutoStart: (id: string): Promise => diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index c5307266a25..c906836cfa2 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -16,7 +16,7 @@ type BuildSourceControlAgentDeliveryPlanArgs = { /** Why: keep the previewed command label in sync with the real remote launch, * which omits the Linux-only `orca-ide` rename for SSH hosts. */ isRemote?: boolean - launchHostIsPaired?: boolean + runtimeEnvironmentId?: string | null } export function buildSourceControlAgentDeliveryPlan({ @@ -28,7 +28,7 @@ export function buildSourceControlAgentDeliveryPlan({ connectionUnavailable, launchPlatform, isRemote, - launchHostIsPaired + runtimeEnvironmentId }: BuildSourceControlAgentDeliveryPlanArgs): SourceControlAgentActionDeliveryPlanState { if (connectionUnavailable) { return buildSourceControlAgentConnectionErrorPlan() @@ -53,7 +53,7 @@ export function buildSourceControlAgentDeliveryPlan({ terminalWindowsShell: settings?.terminalWindowsShell, platform: launchPlatform, isRemote, - launchHostIsPaired + runtimeEnvironmentId }) if (!result.ok) { return { status: 'error', error: result.error } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 85fe301b820..08fadee4c25 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -10,7 +10,6 @@ import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan' import { useAppStore } from '@/store' -import { launchHostIsPaired } from '@/lib/launch-file-host' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart' @@ -120,9 +119,7 @@ export function useSourceControlAgentActionStart({ connectionUnavailable, launchPlatform, isRemote, - launchHostIsPaired: launchHostIsPaired( - getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) - ) + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) }) }, [ diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index d942af2701f..960291aa1e3 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -21,7 +21,7 @@ import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { composerAgentStartupPlan, type ComposerAgentStartupPlan @@ -129,7 +129,12 @@ export async function submitFolderWorkspaceCreate({ platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, - launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId, + launchPlatform, + isRemote: launchIsRemote + }), + canPasteAfterReady: true }), note ) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 1f48e5233e7..8bfefea7195 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -15,7 +15,9 @@ describe('the full composer’s renderer-spawned startup', () => { prompt, cmdOverrides: {}, platform: 'linux', - isRemote: true + isRemote: true, + host: { paired: false, provesAgentInFront: true }, + canPasteAfterReady: true }), prompt ) diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index 98c273cde24..68a1fa6df82 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -39,7 +39,7 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' @@ -205,7 +205,12 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, - launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId: selectedRepoSettings?.activeRuntimeEnvironmentId, + launchPlatform: selectedRepoAgentLaunchPlatform, + isRemote: selectedRepoIsRemote + }), + canPasteAfterReady: true }), submitStartupPrompt ) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 37a57f9056b..104f8f24b75 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -40,7 +40,7 @@ import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' @@ -140,7 +140,11 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, - launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId), + host: clientLaunchHost({ + runtimeEnvironmentId: selectedRepoSettings?.activeRuntimeEnvironmentId, + launchPlatform: selectedRepoAgentLaunchPlatform, + isRemote: selectedRepoIsRemote + }), telemetrySource }) diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 2b2eed7538f..2a8feb0143a 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' +import type { LaunchHost } from '../../../../shared/launch-host' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { buildAgentDraftLaunchPlan, planLaunchPrompt } from '@/lib/tui-agent-startup' @@ -25,8 +26,8 @@ export type QuickComposerStartupInput = { platform: NodeJS.Platform shell: AgentStartupShell | null | undefined isRemote: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired: boolean + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost telemetrySource: WorktreeCreationRequest['telemetrySource'] } @@ -96,7 +97,8 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui platform: input.platform, shell: input.shell ?? undefined, isRemote: input.isRemote, - launchHostIsPaired: input.launchHostIsPaired + host: input.host, + canPasteAfterReady: true }), prompt ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a79fb299069..f0a26c2b480 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -657,6 +657,11 @@ "message": "The agent wasn't started: Orca couldn't write the file that carries your prompt. Copy the prompt and launch again." } } + }, + "unconfirmed": { + "notice": { + "message": "Orca couldn't confirm the agent received your prompt. Check the agent before sending it again." + } } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 555445f4816..90f0660d695 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -416,6 +416,11 @@ "message": "El agente no se inició: Orca no pudo escribir el archivo que contiene tu prompt. Copia el prompt e inícialo de nuevo." } } + }, + "unconfirmed": { + "notice": { + "message": "Orca no pudo confirmar que el agente recibió tu prompt. Revisa el agente antes de enviarlo de nuevo." + } } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 34857cb55bf..c70852ae279 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -626,6 +626,11 @@ "message": "L’agent n’a pas démarré : Orca n’a pas pu écrire le fichier qui contient votre prompt. Copiez le prompt et relancez." } } + }, + "unconfirmed": { + "notice": { + "message": "Orca n’a pas pu confirmer que l’agent a reçu votre prompt. Vérifiez l’agent avant de le renvoyer." + } } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e1b10ffa8c9..3e61e025a82 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -527,6 +527,11 @@ "message": "Agent は起動しませんでした。プロンプトを渡すファイルを Orca が書き込めませんでした。プロンプトをコピーして、もう一度起動してください。" } } + }, + "unconfirmed": { + "notice": { + "message": "Agent がプロンプトを受け取ったことを Orca で確認できませんでした。再送する前に Agent を確認してください。" + } } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 02a7b6200f4..cc7615728b2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -527,6 +527,11 @@ "message": "에이전트가 시작되지 않았습니다. Orca가 프롬프트를 담을 파일을 쓰지 못했습니다. 프롬프트를 복사한 뒤 다시 시작하세요." } } + }, + "unconfirmed": { + "notice": { + "message": "에이전트가 프롬프트를 받았는지 Orca가 확인하지 못했습니다. 다시 보내기 전에 에이전트를 확인하세요." + } } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 033b8e741c6..ba7aaac7f85 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -527,6 +527,11 @@ "message": "代理未启动:Orca 无法写入承载你的提示词的文件。请复制提示词并重新启动。" } } + }, + "unconfirmed": { + "notice": { + "message": "Orca 无法确认代理已收到你的提示词。再次发送前请先检查代理。" + } } } } diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index fe0ce62c4a9..6c25f3cf342 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -4,7 +4,7 @@ import { clearPreHandlerPtyState, drainPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer' -import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { bracketedPasteStateAfter, waitForAgentDraftInputReady } from './agent-draft-readiness' const testState = vi.hoisted(() => ({ observer: null as ((data: string) => void) | null, @@ -52,4 +52,60 @@ describe('waitForAgentDraftInputReady', () => { ]) expect(vi.getTimerCount()).toBe(0) }) + + // Why: a shell running the launch line turns bracketed paste off, so its earlier prompt must not + // read as the agent ready once the line is running. + describe('with bracketed paste turned off revoking the quiet window', () => { + const quietTimers = (): void => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms), + clearTimeout: (timer: ReturnType) => globalThis.clearTimeout(timer) + }) + } + afterEach(() => vi.unstubAllGlobals()) + + it('waits for the agent to turn it on again', async () => { + quietTimers() + let ready: boolean | null = null + void waitForAgentDraftInputReady( + PTY_ID, + 20_000, + 'render-quiet-after-bracketed-paste', + {}, + { + revokeOnBracketedPasteOff: true + } + ).then((value) => { + ready = value + }) + testState.observer?.('$ \x1b[?2004h') + testState.observer?.('claude "fix it"\r\n\x1b[?2004l') + await vi.advanceTimersByTimeAsync(3_000) + expect(ready).toBeNull() + testState.observer?.('\x1b[?2004h> ') + await vi.advanceTimersByTimeAsync(1_500) + expect(ready).toBe(true) + }) + + it('leaves a caller that did not ask for it on the shell prompt’s quiet window', async () => { + quietTimers() + const pending = waitForAgentDraftInputReady( + PTY_ID, + 20_000, + 'render-quiet-after-bracketed-paste', + {} + ) + testState.observer?.('$ \x1b[?2004h') + testState.observer?.('\x1b[?2004l') + await vi.advanceTimersByTimeAsync(1_500) + await expect(pending).resolves.toBe(true) + }) + + it('reads the last toggle in a chunk', () => { + expect(bracketedPasteStateAfter(true, 'a\x1b[?2004lb')).toBe(false) + expect(bracketedPasteStateAfter(false, '\x1b[?2004l\x1b[?2004h')).toBe(true) + expect(bracketedPasteStateAfter(false, 'plain')).toBe(false) + }) + }) }) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 25be94193da..5f993edac29 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -7,6 +7,15 @@ import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-strea import { createDraftPasteReadyScanner } from '../../../shared/draft-paste-ready-scanner' const BRACKETED_PASTE_QUIET_MS = 1500 +const BRACKETED_PASTE_ON = '\x1b[?2004h' +const BRACKETED_PASTE_OFF = '\x1b[?2004l' + +/** Whether bracketed paste is on after `data`, from the last toggle in it; `previous` if none. */ +export function bracketedPasteStateAfter(previous: boolean, data: string): boolean { + const on = data.lastIndexOf(BRACKETED_PASTE_ON) + const off = data.lastIndexOf(BRACKETED_PASTE_OFF) + return on === -1 && off === -1 ? previous : on > off +} /** * Tap the PTY data stream as a side-channel observer (does NOT take over @@ -22,7 +31,10 @@ export function waitForAgentDraftInputReady( ptyId: string, timeoutMs: number, readySignal: DraftPasteReadySignal, - settings: Pick | null | undefined + settings: Pick | null | undefined, + /** A quiet window counts only while bracketed paste is still on: a shell running the launch + * line turns it off (`2004l`), so its earlier prompt no longer reads as the agent ready. */ + options: { revokeOnBracketedPasteOff?: boolean } = {} ): Promise { return new Promise((resolve) => { let settled = false @@ -53,13 +65,24 @@ export function waitForAgentDraftInputReady( quietTimer = window.setTimeout(() => finish(true), BRACKETED_PASTE_QUIET_MS) } + let toggleCarry = '' + let bracketedPasteOn = true const observeData = (data: string): void => { const { ready, armQuietTimer: shouldArm } = scanner.observe(data) if (ready) { finish(true) return } - if (shouldArm) { + if (options.revokeOnBracketedPasteOff) { + const recent = toggleCarry + data + toggleCarry = recent.slice(-(BRACKETED_PASTE_OFF.length - 1)) + bracketedPasteOn = bracketedPasteStateAfter(bracketedPasteOn, recent) + if (!bracketedPasteOn && quietTimer !== null) { + window.clearTimeout(quietTimer) + quietTimer = null + } + } + if (shouldArm && bracketedPasteOn) { armQuietTimer() } } diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 9323a9aa5a4..b9bdf655892 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -40,7 +40,7 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ - waitForLaunchPromptReceipt: vi.fn(async () => true) + waitForLaunchPromptReceipt: vi.fn(async () => 'delivered') })) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) diff --git a/src/renderer/src/lib/agent-launch-caller-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index d2721aa2805..282dc6eb7ef 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -40,7 +40,7 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ - waitForLaunchPromptReceipt: vi.fn(async () => true) + waitForLaunchPromptReceipt: vi.fn(async () => 'delivered') })) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index 696d3e40423..6155a7e51ab 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -48,7 +48,7 @@ vi.mock('@/lib/agent-paste-draft', () => ({ vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) -const mockWaitForLaunchPromptReceipt = vi.fn(async () => true) +const mockWaitForLaunchPromptReceipt = vi.fn(async () => 'delivered') vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt })) diff --git a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts index 06f1afddd30..feb0af95c36 100644 --- a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts +++ b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts @@ -25,6 +25,28 @@ export function showAgentLaunchPromptNotDeliveredNotice(args: { }) } +/** + * The agent started with the prompt on its command line, but nothing proved it received it: no hook + * turn, and the host cannot tell what holds the terminal. Not a "wasn't sent", so the user is told + * to check the agent before sending the prompt again. + */ +export function showAgentLaunchPromptUnconfirmedNotice(args: { + agent: TuiAgent + prompt: string +}): void { + showPromptCopyNotice( + translate( + 'auto.lib.agent.launch.prompt.unconfirmed.notice.message', + "Orca couldn't confirm the agent received your prompt. Check the agent before sending it again." + ), + args.prompt + ) + track('agent_error', { + error_class: 'paste_readiness_timeout', + agent_kind: tuiAgentToAgentKind(args.agent) + }) +} + /** * The host refused to start the agent because it could not write the launch file that carries the * prompt. Nothing ran, so the user is handed the text to launch again with. diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts index 475a77660a6..25080f8854b 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type ReceiptState = { tabsByWorktree: Record @@ -8,12 +8,13 @@ type ReceiptState = { } const mocks = vi.hoisted(() => { - let state: ReceiptState = { + const empty = (): ReceiptState => ({ tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} - } + }) + let state = empty() const listeners = new Set<(next: ReceiptState) => void>() return { getState: () => state, @@ -29,89 +30,114 @@ const mocks = vi.hoisted(() => { }, reset: () => { listeners.clear() - state = { tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} } + state = empty() }, readiness: vi.fn(), - waitForAgentReady: vi.fn() + readForeground: vi.fn() } }) vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe } })) +vi.mock('./agent-draft-readiness', () => ({ waitForAgentDraftInputReady: mocks.readiness })) vi.mock('./agent-paste-draft', () => ({ PTY_SPAWN_TIMEOUT_MS: 8000, - getSettingsForAgentTabRuntimeOwner: () => ({}), - waitForAgentDraftInputReadyOnTab: mocks.readiness + getSettingsForAgentTabRuntimeOwner: () => ({}) })) -vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: mocks.waitForAgentReady })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) import { waitForLaunchPromptReceipt } from './agent-launch-prompt-receipt' -const never = (): Promise => new Promise(() => {}) +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function receipt() { + return waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) +} describe('whether a prompt that rode the launch command reached the agent', () => { beforeEach(() => { mocks.reset() mocks.setState({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) - mocks.readiness.mockReset().mockImplementation(never) - mocks.waitForAgentReady.mockReset() + mocks.readiness.mockReset().mockResolvedValue(true) + mocks.readForeground.mockReset().mockResolvedValue('agent') + // Timers go through globalThis at call time, so fake timers reach them. + vi.stubGlobal('window', { + setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms), + clearTimeout: (timer: ReturnType) => globalThis.clearTimeout(timer), + api: { pty: { readLaunchedAgentForeground: mocks.readForeground } } + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() }) it('is delivered when the agent’s own hook reports a turn on the tab', async () => { - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.readiness.mockReturnValue(new Promise(() => {})) + const pending = receipt() mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) - mocks.setState({ - agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 150 } } - }) - await expect(receipt).resolves.toBe(true) + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 150 } } }) + await expect(pending).resolves.toBe('delivered') }) - it('ignores a status row older than the launch', async () => { - mocks.setState({ - agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 50 } } - }) - mocks.readiness.mockResolvedValue(null) - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) - await expect(receipt).resolves.toBe(false) + // Why: #24257's crash-guard predicate, asked once the agent looks ready. + it('is delivered when a fresh read finds the launched agent in front', async () => { + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await expect(pending).resolves.toBe('delivered') + expect(mocks.readForeground).toHaveBeenCalledWith('pty-1', 'claude') + expect(mocks.readiness.mock.calls[0]?.[4]).toEqual({ revokeOnBracketedPasteOff: true }) }) - it('is not delivered when the PTY exits before the agent shows any sign of the prompt', async () => { - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + // Why: a shell back at its prompt looks ready too; readiness alone never counts. + it('is not delivered when the read finds the shell, however ready it looked', async () => { + mocks.readForeground.mockResolvedValue('shell') + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 50 } } }) + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await expect(pending).resolves.toBe('not-delivered') + }) + + it('is unconfirmed when the host cannot tell and no hook turn arrives', async () => { + vi.useFakeTimers() + mocks.readForeground.mockResolvedValue('unknown') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(2_000) + await expect(pending).resolves.toBe('unconfirmed') + }) + + it('takes a hook turn that arrives after a read that could not tell', async () => { + vi.useFakeTimers() + mocks.readForeground.mockResolvedValue('unknown') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(500) + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 600 } } }) + await expect(pending).resolves.toBe('delivered') + }) + + it('is not delivered when the PTY exits first', async () => { + mocks.readiness.mockReturnValue(new Promise(() => {})) + const pending = receipt() mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) - await expect(receipt).resolves.toBe(false) + await expect(pending).resolves.toBe('not-delivered') }) it('is not delivered when the PTY never spawns, as when the host refused its launch file', async () => { - mocks.readiness.mockResolvedValue(null) - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) - await expect(receipt).resolves.toBe(false) - }) - - it('is delivered when the agent shows the composer a paste would have waited for', async () => { - mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: true }) - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) - await expect(receipt).resolves.toBe(true) - }) - - it('reports an unconfirmed delivery when only the agent process was seen', async () => { - mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: false }) - mocks.waitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) - const onUnconfirmedDelivery = vi.fn() - const receipt = waitForLaunchPromptReceipt({ - tabId: 'tab-1', - agent: 'codex', - launchedAt: 100, - onUnconfirmedDelivery - }) - await expect(receipt).resolves.toBe(true) - expect(onUnconfirmedDelivery).toHaveBeenCalledOnce() + vi.useFakeTimers() + const pending = receipt() + await vi.advanceTimersByTimeAsync(8_000) + await expect(pending).resolves.toBe('not-delivered') + expect(mocks.readForeground).not.toHaveBeenCalled() }) it('is not delivered when the tab closes first', async () => { - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + const pending = receipt() mocks.setState({ tabsByWorktree: { 'wt-1': [] } }) - await expect(receipt).resolves.toBe(false) + await expect(pending).resolves.toBe('not-delivered') }) }) diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.ts index e2ef5c2c00a..23d58bd026e 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.ts @@ -3,15 +3,17 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { parsePaneKey } from '../../../shared/stable-pane-id' import { useAppStore } from '@/store' -import { waitForAgentReady } from './agent-ready-wait' -import { - getSettingsForAgentTabRuntimeOwner, - PTY_SPAWN_TIMEOUT_MS, - waitForAgentDraftInputReadyOnTab -} from './agent-paste-draft' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { getSettingsForAgentTabRuntimeOwner, PTY_SPAWN_TIMEOUT_MS } from './agent-paste-draft' type AppState = ReturnType +export type LaunchPromptReceipt = 'delivered' | 'not-delivered' | 'unconfirmed' + +/** How long a hook turn may still arrive after a read that could not tell. */ +const HOOK_GRACE_AFTER_UNKNOWN_READ_MS = 2000 + function hookReportedTurnOnTab(state: AppState, tabId: string, launchedAt: number): boolean { return Object.entries(state.agentStatusByPaneKey).some( ([paneKey, entry]) => parsePaneKey(paneKey)?.tabId === tabId && entry.updatedAt >= launchedAt @@ -22,31 +24,68 @@ function tabExists(state: AppState, tabId: string): boolean { return Object.values(state.tabsByWorktree).some((tabs) => tabs?.some((tab) => tab.id === tabId)) } +async function readLaunchedAgentForeground( + ptyId: string, + agent: TuiAgent +): Promise<'agent' | 'shell' | 'unknown'> { + // A paired host's process reads do not reach this client. + if (isRemoteRuntimePtyId(ptyId)) { + return 'unknown' + } + return window.api.pty.readLaunchedAgentForeground(ptyId, agent).catch(() => 'unknown' as const) +} + /** - * Whether a prompt that rode an agent's launch command reached the agent. True at the first real - * signal: the agent's own hook reports a turn on the tab, or the agent shows the composer a paste - * would have waited for. False when the PTY never spawns (a refused launch file included), exits - * first, or the tab closes. Never "the tab exists". + * Whether a prompt that rode an agent's launch command reached the agent, proven the way #24257's + * crash guard proves an agent before a paste. Delivered on the agent's own hook turn on the tab, or + * a fresh read on the execution host that finds the launched agent, not its shell, in front of the + * terminal (`readLaunchedAgentForeground`). A ready signal never counts on its own: it only times + * that read, and bracketed paste turned off (`2004l`) revokes it. Not delivered when the PTY never + * spawns (a refused launch file included), exits first, the tab closes, or the read finds the + * shell. Unconfirmed when nothing proves either, as on a Windows host without hooks. */ export function waitForLaunchPromptReceipt(args: { tabId: string agent: TuiAgent launchedAt: number - /** The agent process was seen, but neither its hook nor its composer. */ - onUnconfirmedDelivery?: () => void -}): Promise { +}): Promise { const { tabId, agent, launchedAt } = args return new Promise((resolve) => { let settled = false - let sawPty = false + let boundPtyId: string | null = null let unsubscribe: (() => void) | null = null - const finish = (delivered: boolean): void => { + const timers: number[] = [] + const finish = (receipt: LaunchPromptReceipt): void => { if (settled) { return } settled = true unsubscribe?.() - resolve(delivered) + timers.forEach((timer) => window.clearTimeout(timer)) + resolve(receipt) + } + const readAfterReadiness = (ptyId: string): void => { + const config = TUI_AGENT_CONFIG[agent] + void waitForAgentDraftInputReady( + ptyId, + resolveDraftPasteReadyTimeoutMs(agent), + config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste', + getSettingsForAgentTabRuntimeOwner(tabId), + { revokeOnBracketedPasteOff: true } + ) + .then(() => readLaunchedAgentForeground(ptyId, agent)) + .then((foreground) => { + if (foreground === 'agent') { + finish('delivered') + } else if (foreground === 'shell') { + finish('not-delivered') + } else { + timers.push( + window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS) + ) + } + }) + .catch(() => finish('unconfirmed')) } let scannedStatus: AppState['agentStatusByPaneKey'] | null = null const observe = (state: AppState): void => { @@ -54,45 +93,26 @@ export function waitForLaunchPromptReceipt(args: { if (state.agentStatusByPaneKey !== scannedStatus) { scannedStatus = state.agentStatusByPaneKey if (hookReportedTurnOnTab(state, tabId, launchedAt)) { - finish(true) + finish('delivered') return } } - if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) { - sawPty = true - } else if (sawPty || !tabExists(state, tabId)) { - finish(false) + const ptyId = state.ptyIdsByTabId[tabId]?.[0] + if (ptyId && !boundPtyId) { + boundPtyId = ptyId + readAfterReadiness(ptyId) + } else if ((boundPtyId && !ptyId) || !tabExists(state, tabId)) { + finish('not-delivered') } } unsubscribe = useAppStore.subscribe(observe) - observe(useAppStore.getState()) - const config = TUI_AGENT_CONFIG[agent] - void waitForAgentDraftInputReadyOnTab({ - tabId, - spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, - readinessTimeoutMs: resolveDraftPasteReadyTimeoutMs(agent), - readySignal: config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste', - settings: getSettingsForAgentTabRuntimeOwner(tabId) - }).then( - async (readiness) => { - if (settled) { - return + timers.push( + window.setTimeout(() => { + if (!boundPtyId) { + finish('not-delivered') } - if (!readiness) { - finish(false) - return - } - if (readiness.ready) { - finish(true) - return - } - const fallback = await waitForAgentReady(tabId, config.expectedProcess, { timeoutMs: 1000 }) - if (fallback.ready && !settled) { - args.onUnconfirmedDelivery?.() - } - finish(fallback.ready) - }, - () => finish(false) + }, PTY_SPAWN_TIMEOUT_MS) ) + observe(useAppStore.getState()) }) } diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 510ebb1aae0..2353e268896 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -273,7 +273,7 @@ async function sendBracketedPasteToAgent(args: { } } -export function waitForAgentDraftInputReadyOnTab(args: { +function waitForAgentDraftInputReadyOnTab(args: { tabId: string spawnTimeoutMs: number readinessTimeoutMs: number diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index d2a54bf3d7a..1334029413b 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -372,9 +372,9 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', }) // Why: a paired host is sent a command and never a launch file, so a pointer would name nothing. - it('pastes a prompt an old paired host would need a launch file for, instead of pointing at one', async () => { + it('pastes a long prompt to an old paired Linux host, which may neither stage it nor read a file', async () => { useRemoteAgentBackgroundRuntime(state) - mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { if (request.method === 'status.get') { return Promise.resolve({ @@ -413,9 +413,49 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', ) }) - it('leaves that prompt to a paired host that takes the prompt itself, without a second paste', async () => { + // Why: a Windows host cannot prove the agent is in front to paste into, so the line carries it. + it('keeps that prompt on an old paired Windows host’s line, as main typed it', async () => { useRemoteAgentBackgroundRuntime(state) mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { + if (request.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + }) + } + return Promise.resolve({ + id: 'create', + ok: true, + result: { terminal: { handle: 'legacy-terminal-1' } } + }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'fix the build\nthen run the tests' + }) + + const create = mockRuntimeEnvironmentTransportCall.mock.calls.find( + ([request]) => request.method === 'terminal.create' + )?.[0] + expect(create?.params?.command).not.toContain('orca-launch-file') + expect(create?.params?.command).toContain('fix the build') + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + }) + + it('leaves that prompt to a paired host that takes the prompt itself, without a second paste', async () => { + useRemoteAgentBackgroundRuntime(state) + mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') await launchAgentBackgroundSession({ diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 8ef6c3fe556..34839d54905 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { @@ -70,7 +70,6 @@ export async function launchAgentBackgroundSession( terminalWindowsShell: store.settings?.terminalWindowsShell }) const trimmedPrompt = prompt?.trim() ?? '' - const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = requireTuiAgentConfig(agent).promptInjectionMode === 'stdin-after-start' // Route by the worktree's owner host, not the focused runtime. @@ -85,7 +84,12 @@ export async function launchAgentBackgroundSession( platform: launchPlatform, shell: startupShell, isRemote, - launchHostIsPaired: launchHostIsPaired(ownerSettings?.activeRuntimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId: ownerSettings?.activeRuntimeEnvironmentId, + launchPlatform, + isRemote + }), + canPasteAfterReady: true }) if (!planned) { return null @@ -108,7 +112,7 @@ export async function launchAgentBackgroundSession( promptLeftForPaste = isFollowupPath ? null : planned.text break } - let pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste + let pasteDraftAfterLaunch = trimmedPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste // A hidden run tab must never be store-visible without its PTY (#2989). const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } = @@ -174,7 +178,7 @@ export async function launchAgentBackgroundSession( tabId: reservedTabId, leafId, agent, - ...(hasPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}), + ...(trimmedPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}), ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), legacy: { command: startupPlan.launchCommand, @@ -247,7 +251,7 @@ export async function launchAgentBackgroundSession( tab = adopted.tab paneKey = adopted.paneKey terminalOwnership = adopted.terminalOwnership - if (agent === 'command-code' && hasPrompt && !isFollowupPath) { + if (agent === 'command-code' && trimmedPrompt && !isFollowupPath) { // Why: Command Code does not expose a prompt-start hook; seed working for // hidden prompt launches so sidebar/activity surfaces do not stay idle. const routing = agentStatusConsumer.resolveRouting() diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index d44a7bb6739..4a18aa3a379 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -142,7 +142,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({ describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() - mockWaitForLaunchPromptReceipt.mockResolvedValue(true) + mockWaitForLaunchPromptReceipt.mockResolvedValue('delivered') mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) @@ -867,7 +867,7 @@ describe('launchAgentInNewTab', () => { it('reports a carried prompt undelivered when the agent never received it', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - mockWaitForLaunchPromptReceipt.mockResolvedValue(false) + mockWaitForLaunchPromptReceipt.mockResolvedValue('not-delivered') const onPromptDelivered = vi.fn() const result = launchAgentInNewTab({ diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 096a2241d5c..6b0f5801809 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,7 +3,7 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { deliverNewTabLaunchPrompt } from '@/lib/launch-agent-new-tab-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' @@ -160,7 +160,11 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent prompt: trimmedPrompt, promptDelivery, isFollowupPath, - launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId, + launchPlatform: resolvedLaunchPlatform, + isRemote + }) }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined diff --git a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts index 4bb2e43ab66..bab1b8759d5 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts @@ -1,5 +1,6 @@ import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt' +import { showAgentLaunchPromptUnconfirmedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' import { deliverLaunchPromptToAgentTab, seedNativeChatLaunchDraftForAgentTab, @@ -60,9 +61,18 @@ export function deliverNewTabLaunchPrompt(args: { content }) // Why the receipt for a carried prompt: callers post replies and resolve threads on this result. - const delivery = + const result = pasteDraftAfterLaunch === null - ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt, ...unconfirmed }) + ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt }).then( + (receipt) => { + if (receipt !== 'unconfirmed') { + return timeoutNotice.settle(receipt === 'delivered', args.onPromptDelivered) + } + // The agent may have the prompt: say so, and never invite pasting it a second time. + showAgentLaunchPromptUnconfirmedNotice({ agent, prompt }) + return { delivered: false, failureNotified: true } + } + ) : deliverLaunchPromptToAgentTab({ tabId, content, @@ -71,10 +81,7 @@ export function deliverNewTabLaunchPrompt(args: { forcePaste: true, onTimeout: timeoutNotice.onTimeout, ...unconfirmed - }) - const result = delivery.then((delivered) => - timeoutNotice.settle(delivered, args.onPromptDelivered) - ) + }).then((delivered) => timeoutNotice.settle(delivered, args.onPromptDelivered)) if (promptDelivery === 'submit-after-ready') { return result } diff --git a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts index f82379f99a4..7e41a5fdf16 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -6,6 +6,7 @@ import { type AgentStartupPlanInputs } from '@/lib/tui-agent-startup' import type { LaunchFile } from '../../../shared/launch-prompt-file' +import type { LaunchHost } from '../../../shared/launch-host' export type LaunchAgentStartupPromptPlan = { startupPlan: AgentStartupPlan | null @@ -26,8 +27,8 @@ export function planLaunchAgentStartupPrompt(args: { prompt: string promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' isFollowupPath: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired: boolean + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost }): LaunchAgentStartupPromptPlan { const { base, prompt, promptDelivery, isFollowupPath } = args const pasteAfterReady = ( @@ -65,19 +66,13 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } } - if (prompt.length > 0 && isFollowupPath) { - return pasteAfterReady(cleanPlan(), prompt, promptDelivery === 'submit-after-ready') - } - // Why: a caller that waits for delivery gets a verdict from a paste; this client cannot observe a - // paired host's carried prompt reach its agent. Temporary, until paired hosts report that receipt. - if (prompt.length > 0 && args.launchHostIsPaired && promptDelivery === 'submit-after-ready') { + // Why outside the rule: this is the caller's need, not a host fact. A caller that waits for + // delivery gets a verdict from a paste, and this client cannot observe a paired host's carried + // prompt reach its agent. Temporary, until paired hosts report that receipt. + if (prompt.length > 0 && args.host.paired && promptDelivery === 'submit-after-ready') { return pasteAfterReady(cleanPlan(), prompt, true) } - const planned = planLaunchPrompt({ - ...base, - prompt, - launchHostIsPaired: args.launchHostIsPaired - }) + const planned = planLaunchPrompt({ ...base, prompt, host: args.host, canPasteAfterReady: true }) if (!planned) { return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false } } @@ -93,7 +88,11 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } case 'paste-after-ready': - // Every non-draft new-tab prompt is submitted; a draft returned above. - return pasteAfterReady(planned.cleanPlan, planned.text, true) + // An agent that takes text only after start keeps an auto-submit prompt as an editable draft. + return pasteAfterReady( + planned.cleanPlan, + planned.text, + !isFollowupPath || promptDelivery === 'submit-after-ready' + ) } } diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index 299c4672db5..4dbdb125d40 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -23,10 +23,10 @@ describe('the quick composer on a paired host', () => { draftPrompt: null, settings: null, repoConnectionId: null, - platform: 'win32', - shell: 'powershell', + platform: 'linux', + shell: null, isRemote: false, - launchHostIsPaired: true, + host: { paired: true, provesAgentInFront: true }, telemetrySource: 'sidebar' }) expect(startup.backendStartup).toBeUndefined() diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts index cb49325168c..c1b7e5ed77f 100644 --- a/src/renderer/src/lib/launch-file-host.ts +++ b/src/renderer/src/lib/launch-file-host.ts @@ -1,12 +1,27 @@ import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { describeLaunchHost, type LaunchHost } from '../../../shared/launch-host' import { isWebClientLocation } from './web-client-location' /** * Whether a launch lands on a paired Orca: another Orca this client drives, which may be an older - * build that neither stages a long line nor writes a launch file. `carryLaunchPrompt` then keeps - * the line to the typed budget and pastes the rest after the agent is ready. Temporary, until paired - * hosts advertise both. + * build that neither stages a long line nor writes a launch file. Temporary, until paired hosts + * advertise both. */ export function launchHostIsPaired(runtimeEnvironmentId: string | null | undefined): boolean { return isWebClientLocation() || isWebRuntimeSessionActive(runtimeEnvironmentId) } + +/** The host facts for a launch this client starts, from the environment it targets. */ +export function clientLaunchHost(args: { + runtimeEnvironmentId: string | null | undefined + launchPlatform: NodeJS.Platform + isRemote: boolean +}): LaunchHost { + return describeLaunchHost({ + launchPlatform: args.launchPlatform, + isRemote: args.isRemote, + hostPlatform: CLIENT_PLATFORM, + paired: launchHostIsPaired(args.runtimeEnvironmentId) + }) +} diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 8dee872c816..7d30fdb2d04 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -3,6 +3,7 @@ import type { AppState } from '@/store/types' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection' import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' +import { clientLaunchHost } from '@/lib/launch-file-host' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' @@ -18,7 +19,7 @@ export function buildDirectWorkItemStartup(args: { launchConnectionId: string | null worktreePath: string repoProjectRuntime?: Parameters[0]['projectRuntime'] - launchHostIsPaired: boolean + runtimeEnvironmentId: string | null }): ReturnType { const launchPlatform = args.launchPlatform ?? @@ -39,7 +40,11 @@ export function buildDirectWorkItemStartup(args: { ), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied. isRemote: typeof args.launchConnectionId === 'string', - launchHostIsPaired: args.launchHostIsPaired + host: clientLaunchHost({ + runtimeEnvironmentId: args.runtimeEnvironmentId, + launchPlatform, + isRemote: typeof args.launchConnectionId === 'string' + }) }) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index 44b38b65066..1b90b6f6e8c 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -83,7 +83,7 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -98,7 +98,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -147,7 +147,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -161,7 +161,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -177,9 +177,9 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => }) describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { - const submit = (agent: 'claude' | 'gemini', draftContent: string, launchHostIsPaired = false) => + const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) => buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired, + host: { paired, provesAgentInFront: true }, agent, draftContent, promptDelivery: 'submit-after-ready', diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index cdd32ec8bed..6a75ba2fc8a 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -19,6 +19,7 @@ import { translate } from '@/i18n/i18n' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' import type { LaunchFile } from '../../../shared/launch-prompt-file' +import type { LaunchHost } from '../../../shared/launch-host' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -41,8 +42,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired: boolean + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost }): { startupPlan: AgentStartupPlan | null launchFile?: LaunchFile @@ -81,7 +82,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { const planned = planLaunchPrompt({ ...planInputs, prompt: args.draftContent, - launchHostIsPaired: args.launchHostIsPaired + host: args.host, + canPasteAfterReady: true }) if (!planned) { return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true } diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index f19f8632015..b968b74fb0c 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -4,7 +4,6 @@ import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { launchHostIsPaired } from '@/lib/launch-file-host' import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { AgentSessionLaunchPlan, @@ -93,9 +92,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { CLIENT_PLATFORM ) ?? args.repoProjectRuntime) : undefined, - launchHostIsPaired: launchHostIsPaired( - getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) - ) + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) }) const plan = diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index b32d6cbc669..712d065abde 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -6,6 +6,7 @@ import { type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { clientLaunchHost } from '@/lib/launch-file-host' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../shared/tui-agent' @@ -45,8 +46,8 @@ export function planSourceControlAgentActionLaunch(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired?: boolean + /** The runtime environment the launch targets, for its host facts (`clientLaunchHost`). */ + runtimeEnvironmentId?: string | null }): SourceControlLaunchPlanResult { const agent = args.agent if (!agent) { @@ -104,24 +105,7 @@ export function planSourceControlAgentActionLaunch(args: { let startupPlan: AgentStartupPlan | null = null let delivery: SourceControlLaunchPlanDelivery - // Only an agent that takes no launch prompt waits for readiness; the rest carry it on argv. - if ( - args.promptDelivery === 'submit-after-ready' && - TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' - ) { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'paste-submit' - } else if (args.promptDelivery === 'draft') { + if (args.promptDelivery === 'draft') { const draftLaunchPlan = buildAgentDraftLaunchPlan({ agent, draft: trimmedInput, @@ -161,19 +145,6 @@ export function planSourceControlAgentActionLaunch(args: { }) delivery = 'draft-paste' } - } else if (TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' } else { const planned = planLaunchPrompt({ agent, @@ -184,7 +155,12 @@ export function planSourceControlAgentActionLaunch(args: { isRemote, agentArgs: args.agentArgs, sessionOptions: args.sessionOptions, - launchHostIsPaired: args.launchHostIsPaired ?? false + host: clientLaunchHost({ + runtimeEnvironmentId: args.runtimeEnvironmentId, + launchPlatform: platform, + isRemote + }), + canPasteAfterReady: true }) switch (planned?.carry) { case undefined: @@ -201,7 +177,12 @@ export function planSourceControlAgentActionLaunch(args: { break case 'paste-after-ready': startupPlan = planned.cleanPlan - delivery = 'paste-submit' + // An agent that takes text only after start keeps an auto-submit prompt as a draft. + delivery = + TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' && + args.promptDelivery !== 'submit-after-ready' + ? 'draft-paste' + : 'paste-submit' break } } diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index c4bf707ea3c..e6e95a3c575 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -40,6 +40,7 @@ export function createPtyApi(): NonNullable['pty']> { inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), // Why: paired web panes cannot provide a local post-boundary process scan. confirmForegroundProcess: () => Promise.resolve(null), + readLaunchedAgentForeground: () => Promise.resolve('unknown' as const), // Why: a paired client's terminals belong to the host, whose Codex settings this client does not own. isCodexOnSharedServer: () => Promise.resolve(false), disableCodexSharedServerAutoStart: () => Promise.resolve(false), diff --git a/src/shared/launch-file-writing.test.ts b/src/shared/launch-file-writing.test.ts index cbdf971d4c2..8e0ed8ed90b 100644 --- a/src/shared/launch-file-writing.test.ts +++ b/src/shared/launch-file-writing.test.ts @@ -10,7 +10,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from './child-process/run-process' import { LAUNCH_FILE_STALE_MS, @@ -187,7 +187,39 @@ describePosix('a launch line naming a file under an unusual home directory', () }) describe('sweepStaleLaunchFiles', () => { - it('removes only launch file directories older than a day', () => { + const ageOut = (directory: string, now: number): void => { + const staleSeconds = (now - LAUNCH_FILE_STALE_MS - 1000) / 1000 + utimesSync(directory, staleSeconds, staleSeconds) + } + + // Why: a daemon session outlives app restarts; its agent may re-read its task file any time. + it('never removes a folder whose owning process is alive, however old', () => { + const now = Date.now() + const live = join(baseDirectory, `orca-launch-file-${process.pid}-aaaaaa`) + mkdirSync(live) + ageOut(live, now) + sweepStaleLaunchFiles({ baseDirectory, now }) + expect(readdirSync(baseDirectory)).toEqual([`orca-launch-file-${process.pid}-aaaaaa`]) + }) + + it('removes a folder whose owning process is gone, however new', () => { + const deadPid = 2_147_483_000 + const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === deadPid) { + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + } + return true + }) + try { + mkdirSync(join(baseDirectory, `orca-launch-file-${deadPid}-bbbbbb`)) + sweepStaleLaunchFiles({ baseDirectory }) + expect(readdirSync(baseDirectory)).toEqual([]) + } finally { + kill.mockRestore() + } + }) + + it('falls back to the age backstop for a folder that names no owner', () => { const now = Date.now() const stale = join(baseDirectory, 'orca-launch-file-aaaaaa') const fresh = join(baseDirectory, 'orca-launch-file-bbbbbb') @@ -195,10 +227,19 @@ describe('sweepStaleLaunchFiles', () => { for (const directory of [stale, fresh, unrelated]) { mkdirSync(directory) } - const staleSeconds = (now - LAUNCH_FILE_STALE_MS - 1000) / 1000 - utimesSync(stale, staleSeconds, staleSeconds) - utimesSync(unrelated, staleSeconds, staleSeconds) + ageOut(stale, now) + ageOut(unrelated, now) sweepStaleLaunchFiles({ baseDirectory, now }) expect(readdirSync(baseDirectory).sort()).toEqual(['orca-launch-file-bbbbbb', 'unrelated']) }) + + it('names the owning process in each folder it writes', () => { + const { launchFile } = carryInLaunchFile('x', true) + const written = writeLaunchFile({ + launchFile: { ...launchFile, quoting: 'posix' }, + baseDirectory + }) + expect(written.directory).toMatch(new RegExp(`orca-launch-file-${process.pid}-[^/\\\\]+$`)) + removeLaunchFile(written) + }) }) diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts index b3d55e20b98..78b24dc2756 100644 --- a/src/shared/launch-file-writing.ts +++ b/src/shared/launch-file-writing.ts @@ -25,10 +25,22 @@ import type { WslLaunchDirectory } from './wsl-launch-directory' const LAUNCH_FILE_DIR_PREFIX = 'orca-launch-file-' const LAUNCH_FILE_NAME = 'task-context.md' +/** `orca-launch-file--`: the process whose sessions read the file. */ +const OWNED_LAUNCH_FILE_DIR = /^orca-launch-file-(\d+)-/ -/** A running agent may re-read its task file, so leftovers get a day, not an hour. */ +/** Backstop for a folder that names no owner: a running agent may re-read its task file. */ export const LAUNCH_FILE_STALE_MS = 24 * 60 * 60 * 1000 +/** Whether `pid` is a live process; one this process may not signal still counts as live. */ +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'EPERM' + } +} + // Characters one of the three quotings ends or expands on: `'` (POSIX, PowerShell, which also // honours the typographic single quotes), `"` and `%` (cmd). const QUOTE_SENSITIVE_PATH_CHARACTER = /['"%\u2018-\u201b]/ @@ -111,7 +123,9 @@ export function writeLaunchFile(args: { // Why realpath: an agent matches its read grant against the resolved path (macOS $TMPDIR sits // under /var -> /private/var), so the pointer and the granted directory both name that form. // A UNC path into WSL is named by its Linux path instead, which the distro resolves itself. - const created = mkdtempSync(join(baseDirectory, LAUNCH_FILE_DIR_PREFIX)) + // Why the pid: this process owns the sessions that read the file, which it removes when they + // end; the sweep leaves it alone while this process lives, however long that is. + const created = mkdtempSync(join(baseDirectory, `${LAUNCH_FILE_DIR_PREFIX}${process.pid}-`)) directory = wsl ? created : realpathSync(created) const launchDirectory = wsl ? posix.join(wsl.linuxPath, basename(created)) : directory const path = (wsl ? posix.join : join)(launchDirectory, LAUNCH_FILE_NAME) @@ -219,7 +233,11 @@ export function removeLaunchFile(written: WrittenLaunchFile | undefined): void { } } -/** Removes launch files a crashed host left behind; age-gated so a live agent's file survives. */ +/** + * Removes launch files a crashed host left behind. A folder whose owner process is alive is never + * touched: that process removes it when the session reading it ends. A dead owner's sessions died + * with it. A folder that names no owner falls back to the age backstop. + */ export function sweepStaleLaunchFiles(args: { baseDirectory?: string; now?: number }): void { const baseDirectory = args.baseDirectory ?? tmpdir() const now = args.now ?? Date.now() @@ -234,8 +252,12 @@ export function sweepStaleLaunchFiles(args: { baseDirectory?: string; now?: numb continue } const directory = join(baseDirectory, name) + const owner = OWNED_LAUNCH_FILE_DIR.exec(name)?.[1] try { - if (now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS) { + const orphaned = owner + ? !processIsAlive(Number(owner)) + : now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS + if (orphaned) { rmSync(directory, { recursive: true, force: true }) } } catch { diff --git a/src/shared/launch-host.test.ts b/src/shared/launch-host.test.ts new file mode 100644 index 00000000000..7eb4de191b4 --- /dev/null +++ b/src/shared/launch-host.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { describeLaunchHost } from './launch-host' + +describe('whether a launch host can prove its agent is in front before a paste', () => { + it.each([ + ['a local macOS host', false, false, 'darwin', 'darwin', true], + ['a local Linux host', false, false, 'linux', 'linux', true], + ['a local Windows host', false, false, 'win32', 'win32', false], + // The pane runs in the distro, but the reads run on the Windows host. + ['a local WSL pane', false, false, 'linux', 'win32', false], + ['an SSH Linux host from Windows', true, false, 'linux', 'win32', true], + ['an SSH Windows host', true, false, 'win32', 'darwin', false], + ['a paired Linux Orca from Windows', false, true, 'linux', 'win32', true], + ['a paired Windows Orca from macOS', false, true, 'win32', 'darwin', false] + ] as const)('%s', (_label, isRemote, paired, launchPlatform, hostPlatform, proves) => { + expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({ + paired, + provesAgentInFront: proves + }) + }) +}) diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts new file mode 100644 index 00000000000..01dcdaa8c91 --- /dev/null +++ b/src/shared/launch-host.ts @@ -0,0 +1,28 @@ +/** What the host a launch runs on can do with its prompt, derived in one place from where it runs. */ +export type LaunchHost = { + /** Another Orca this client drives, possibly an older one: it is sent a command it may neither + * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ + paired: boolean + /** Whether the host can prove the launched agent holds its terminal before a paste + * (`launched-agent-foreground`). A Windows host cannot, so it refuses a paste. */ + provesAgentInFront: boolean +} + +/** + * The launch host's facts. An SSH or paired host is judged by its own platform, a local one (a WSL + * pane included, whose process reads run on Windows) by the machine running this Orca. + */ +export function describeLaunchHost(args: { + /** The platform of the shell that types the launch line. */ + launchPlatform: NodeJS.Platform + isRemote: boolean + /** The platform of the machine this Orca runs on. */ + hostPlatform: NodeJS.Platform + paired: boolean +}): LaunchHost { + const runsElsewhere = args.isRemote || args.paired + return { + paired: args.paired, + provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32' + } +} diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index 81b69019970..6714581d266 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -24,14 +24,14 @@ describe('the one launch-prompt decision every launch path builds through', () = ...base, prompt: 'fix it', platform: 'linux', - launchHostIsPaired: true + host: { paired: true, provesAgentInFront: true } }) expect(short?.carry).toBe('on-line') const long = planLaunchForTest({ ...base, prompt: 'fix it\nthen run the tests', platform: 'linux', - launchHostIsPaired: true + host: { paired: true, provesAgentInFront: true } }) expect(long?.carry).toBe('paste-after-ready') expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests') diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 30e8a43d156..d4a18be1e65 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -5,13 +5,31 @@ import type { TuiAgent } from './tui-agent' import { RUNTIME_CAPABILITIES } from './protocol-version' import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' import { TYPED_STARTUP_LINE_BUDGET_BYTES, typedStartupLineFits } from './typed-startup-line' +import { describeLaunchHost } from './launch-host' +import { WINDOWS_TYPED_LINE_MAX_CHARS } from './launch-prompt-carry' function plan( agent: TuiAgent, prompt: string, extra: Partial> = {} ) { - return planLaunchPrompt({ agent, prompt, cmdOverrides: {}, platform: 'darwin', ...extra }) + const platform = extra.platform ?? 'darwin' + // A local launch on that platform, by a caller that pastes, unless the test says otherwise. + const host = describeLaunchHost({ + launchPlatform: platform, + isRemote: false, + hostPlatform: platform, + paired: false + }) + return planLaunchPrompt({ + agent, + prompt, + cmdOverrides: {}, + platform, + host, + canPasteAfterReady: true, + ...extra + }) } describe('where a launch prompt rides', () => { @@ -62,6 +80,15 @@ describe('where a launch prompt rides', () => { expect(planned.cleanPlan.launchCommand).not.toContain('gggg') }) + // Why: main started such an agent with the whole prompt on its line; a caller with no paste + // (agentSession.create, a phone quick command) must not refuse it now. + it('keeps a file-sized prompt on the line for a caller that cannot paste', () => { + const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) + const planned = plan('gemini', prompt, { canPasteAfterReady: false }) + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain('gggg') + }) + it('leaves a stdin-after-start agent’s prompt for the paste', () => { const planned = plan('aider', 'fix it') expect(planned).toMatchObject({ carry: 'paste-after-ready', text: 'fix it' }) @@ -78,48 +105,58 @@ describe('where a launch prompt rides', () => { }) describe('a host that types the line raw', () => { + // Why: the user's own words stay in the agent's history wherever a Windows line carries them. it.each<['cmd' | 'powershell']>([['cmd'], ['powershell']])( - 'keeps a %s line to the typed budget and points Claude at a file past it', + 'keeps an exact %s line up to cmd’s cap and points Claude at a file past it', (shell) => { - const short = plan('claude', 'fix it', { platform: 'win32', shell }) - expect(short?.carry).toBe('on-line') - const long = plan('claude', 'y'.repeat(600), { platform: 'win32', shell }) - expect(long?.carry).toBe('launch-file') + expect(plan('claude', 'fix it', { platform: 'win32', shell })?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(4_000), { platform: 'win32', shell })?.carry).toBe('on-line') + const past = plan('claude', 'y'.repeat(WINDOWS_TYPED_LINE_MAX_CHARS), { + platform: 'win32', + shell + }) + expect(past?.carry).toBe('launch-file') } ) - it('points Codex at a file for a Windows-damaged prompt, and pastes it for Gemini', () => { + // The measured matrix (#23962 W-1): PowerShell splits at `"`, turns a trailing `\` into `"`, and + // a .cmd shim expands `%NAME%`; everything else arrives byte for byte. + it.each([ + ['a double quote', 'say "hi"', 'launch-file'], + ['a trailing backslash', 'see C:\\dir\\', 'launch-file'], + ['a %NAME% pair', 'echo %PATH%', 'launch-file'], + ['a lone percent and an apostrophe', "it's 100% done", 'on-line'] + ] as const)( + 'carries a PowerShell prompt with %s by the measured matrix', + (_label, text, carry) => { + expect(plan('claude', text, { platform: 'win32', shell: 'powershell' })?.carry).toBe(carry) + } + ) + + it('points Codex at a file for a Windows-damaged prompt, and keeps it on Gemini’s line', () => { const prompt = 'fix the build\nthen run the tests' expect(plan('codex', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('launch-file') - expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })).toMatchObject({ - carry: 'paste-after-ready', - text: prompt - }) - }) - - it('points a PowerShell prompt with a double quote at a file', () => { - expect(plan('claude', 'say "hi"', { platform: 'win32', shell: 'powershell' })?.carry).toBe( - 'launch-file' - ) + // A Windows host cannot prove Gemini is in front to paste into, so it types it as main did. + expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('on-line') }) // Why: a host that cannot prove the agent holds its terminal refuses a paste (#24257), so the // line carries what the paste would have, as main typed it; Claude and Codex still get the file. it('keeps a prompt on the line of a host that cannot paste, unless a file can carry it', () => { + // A local WSL pane: a Linux line, on a Windows host that cannot read what holds it. const extra = { - platform: 'win32' as const, - shell: 'cmd' as const, - hostProvesAgentInFront: false + platform: 'linux' as const, + host: { paired: false, provesAgentInFront: false } } const prompt = 'fix the build\nthen run the tests' expect(plan('gemini', prompt, extra)?.carry).toBe('on-line') - expect(plan('gemini', 'y'.repeat(600), extra)?.carry).toBe('on-line') - expect(plan('claude', prompt, extra)?.carry).toBe('launch-file') + expect(plan('gemini', 'y'.repeat(20_000), extra)?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('launch-file') expect(plan('aider', prompt, extra)?.carry).toBe('paste-after-ready') }) it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { - const extra = { platform: 'linux' as const, launchHostIsPaired: true } + const extra = { platform: 'linux' as const, host: { paired: true, provesAgentInFront: true } } expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') expect(plan('claude', 'z'.repeat(20_000), extra)?.carry).toBe('paste-after-ready') diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index cc1eccd7a6e..1c98fb5e4f9 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -14,6 +14,7 @@ import { } from './tui-agent-startup-shell' import { hasControlByte, typedStartupLineFits } from './typed-startup-line' import type { TuiAgent } from './tui-agent' +import type { LaunchHost } from './launch-host' /** * Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control @@ -75,26 +76,41 @@ export type CarriedPlanArgs = { shell?: AgentStartupShell /** The file `prompt` already points at, when the caller wrote its own. */ launchFile?: LaunchFile - /** Another Orca this client drives, possibly an older one: it is sent a command it may neither - * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ - launchHostIsPaired?: boolean - /** False where the host cannot prove the launched agent holds its terminal (a Windows host), - * so it refuses a paste (`launched-agent-foreground`). */ - hostProvesAgentInFront?: boolean + /** The host the launch runs on (`describeLaunchHost`). */ + host: LaunchHost + /** Whether the caller pastes a prompt left for after the agent is ready. One that cannot still + * starts the agent with the prompt on its line, as main did. */ + canPasteAfterReady: boolean } export function agentReadsLaunchFile(agent: TuiAgent): boolean { return TUI_AGENT_CONFIG[agent].readsLaunchFile === true } +/** cmd.exe's documented line cap, the smallest of the Windows shells Orca types into. */ +export const WINDOWS_TYPED_LINE_MAX_CHARS = 8191 + /** - * The one carry rule. The prompt rides the agent's line, which a host that stages (POSIX, SSH, WSL) - * stages when it is long or multi-line. It rides a launch file when it is past the argv ceiling, - * when a Windows shell would damage it, or when the host types the line raw (a Windows host, a - * paired Orca) and the line is past the typed budget. A launch file goes only to an agent measured - * reading one, on a host that writes it; otherwise the agent starts clean and the prompt is pasted - * once it is ready, except where the host cannot prove the agent is in front to paste into: there - * the line carries it, as main typed it. + * Whether a Windows shell carries `prompt` exactly on `line`. Nothing stages a Windows line, so it + * must hold no control byte, fit cmd's cap, and survive the shell's quoting (measured matrix: + * `windowsShellDamagesPrompt`). + */ +function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentStartupShell) { + return ( + !hasControlByte(line) && + line.length <= WINDOWS_TYPED_LINE_MAX_CHARS && + !windowsShellDamagesPrompt(prompt, shell) + ) +} + +/** + * The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL) + * stages it when it is long or multi-line, and a Windows host types it when its shell carries the + * text exactly. It rides a launch file when it is past the argv ceiling, when a Windows line would + * damage it, or past a paired host's typed budget. A launch file goes only to an agent measured + * reading one, on a host that writes it. Otherwise the prompt is pasted once the agent is ready, + * unless the host cannot prove the agent is in front to paste into or the caller has no paste: + * then the line carries it, as main typed it. */ export function carryLaunchPrompt( args: A, @@ -120,12 +136,13 @@ export function carryLaunchPrompt | null => { - const plan = args.hostProvesAgentInFront === false ? buildLine(args) : null + const pasteIsSafe = args.host.provesAgentInFront && args.canPasteAfterReady + const plan = pasteIsSafe ? null : buildLine(args) return plan ? { carry: 'on-line', plan } : pasteAfterReady() } const viaLaunchFile = (): LaunchPromptPlan

| null => { // Why: an agent not measured reading the file would stop on an approval or refuse the path. - if (args.launchHostIsPaired === true || !agentReadsLaunchFile(args.agent)) { + if (args.host.paired || !agentReadsLaunchFile(args.agent)) { return lineOrPaste() } const pointer = carryInLaunchFile(text, false) @@ -134,23 +151,25 @@ export function carryLaunchPrompt MAX_INLINE_LAUNCH_PROMPT_CHARS || - (!readsEnv && windowsShellDamagesPrompt(text, shell)) - ) { + if (text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS) { return viaLaunchFile() } const plan = buildLine(args) + const readsEnv = mode === 'hermes-query' if (!plan) { // Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a // one-character query building proves the budget, not the command, refused it. return readsEnv && buildLine({ ...args, prompt: '.' }) ? viaLaunchFile() : null } - // Why the typed budget (#24257's): nothing stages a line the host types raw, and a long typed - // line is truncated or submitted early. Hermes's line never holds the text. - const hostTypesLineRaw = args.launchHostIsPaired === true || args.platform === 'win32' - if (!readsEnv && hostTypesLineRaw && !typedStartupLineFits(plan.launchCommand)) { + // Hermes's line never holds the text. + if (readsEnv) { + return { carry: 'on-line', plan } + } + if (args.platform === 'win32' && !windowsLineCarriesExactly(text, plan.launchCommand, shell)) { + return viaLaunchFile() + } + // Why #24257's typed budget: an older paired host may type the line raw, truncated past it. + if (args.host.paired && !typedStartupLineFits(plan.launchCommand)) { return viaLaunchFile() } return { carry: 'on-line', plan } diff --git a/src/shared/launch-prompt-file.ts b/src/shared/launch-prompt-file.ts index 0c99fc1251e..62c9e2ebc62 100644 --- a/src/shared/launch-prompt-file.ts +++ b/src/shared/launch-prompt-file.ts @@ -87,7 +87,7 @@ export function parseLaunchFile(value: unknown): LaunchFile | undefined { return { placeholder, content, sensitive, ...(knownQuoting ? { quoting: knownQuoting } : {}) } } -const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable' +export const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable' /** The refusal a host sends when it could not write what carries the prompt (a launch file, or the * staged script holding a long line); it reaches the user as is. */ @@ -106,3 +106,8 @@ export function describeLaunchFileUnavailable( export function isLaunchFileUnavailableMessage(message: string): boolean { return message.includes(LAUNCH_FILE_UNAVAILABLE_CODE) } + +/** The refusal as it reaches main from any host: its code is its type across process boundaries. */ +export function isLaunchFileRefusal(error: unknown): boolean { + return error instanceof Error && isLaunchFileUnavailableMessage(error.message) +} diff --git a/src/shared/launch-prompt-plan.test-fixture.ts b/src/shared/launch-prompt-plan.test-fixture.ts index 782eba00086..2a611152790 100644 --- a/src/shared/launch-prompt-plan.test-fixture.ts +++ b/src/shared/launch-prompt-plan.test-fixture.ts @@ -1,4 +1,5 @@ import type { LaunchFile } from './launch-prompt-file' +import { describeLaunchHost } from './launch-host' import type { LaunchPromptCarry } from './launch-prompt-carry' import { planLaunchPrompt, @@ -17,13 +18,28 @@ export type PlannedLaunchForTest = AgentStartupPlan & { /** `planLaunchPrompt`, flattened; null where it answers null, or for an empty prompt unless * `allowEmptyPromptLaunch`, as the builder did before the outcome was explicit. */ export function planLaunchForTest( - args: AgentLaunchPromptArgs & { allowEmptyPromptLaunch?: boolean } + args: Omit & + Partial> & { + allowEmptyPromptLaunch?: boolean + } ): PlannedLaunchForTest | null { const { allowEmptyPromptLaunch, ...launchArgs } = args if (allowEmptyPromptLaunch !== true && !args.prompt.trim()) { return null } - const planned = planLaunchPrompt(launchArgs) + const planned = planLaunchPrompt({ + ...launchArgs, + // A local launch on the platform under test, by a caller that pastes, unless the test says. + host: + args.host ?? + describeLaunchHost({ + launchPlatform: args.platform, + isRemote: args.isRemote === true, + hostPlatform: args.platform, + paired: false + }), + canPasteAfterReady: args.canPasteAfterReady ?? true + }) if (!planned) { return null } diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index ba7093e9884..23a47218339 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -10,6 +10,7 @@ import { type AgentStartupShell } from './tui-agent-startup-shell' import type { LaunchFile } from './launch-prompt-file' +import type { LaunchHost } from './launch-host' import { carryLaunchPrompt, launchFileDirectoryGrant, @@ -72,10 +73,10 @@ export type AgentLaunchPromptArgs = AgentStartupPlanInputs & { prompt: string /** The file `prompt` already points at, when the caller wrote its own. */ launchFile?: LaunchFile - /** Another Orca this client drives; see `CarriedPlanArgs`. */ - launchHostIsPaired?: boolean /** See `CarriedPlanArgs`. */ - hostProvesAgentInFront?: boolean + host: LaunchHost + /** See `CarriedPlanArgs`. */ + canPasteAfterReady: boolean } /** @@ -89,7 +90,9 @@ export function planLaunchPrompt( } /** Builds the line with `prompt` on it; the carry rule decides whether it should. */ -function buildPlanWithPromptOnLine(args: AgentLaunchPromptArgs): AgentStartupPlan | null { +function buildPlanWithPromptOnLine( + args: AgentStartupPlanInputs & { prompt: string; launchFile?: LaunchFile } +): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 579f2639981..1deb601ea65 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -76,18 +76,18 @@ describe('a prompt a Windows shell would damage on the launch line', () => { ) }) - it('launches clean and leaves the prompt for the paste when the host writes no launch file', () => { + it('keeps the prompt on a paired Windows host’s line, which writes no file and cannot paste', () => { const startup = planLaunchForTest({ agent: 'claude', prompt: 'fix the build\nthen run the tests', cmdOverrides: {}, platform: 'win32', shell: 'powershell', - launchHostIsPaired: true + host: { paired: true, provesAgentInFront: false } }) expect(startup?.launchFile).toBeUndefined() - expect(startup?.launchCommand).toBe('claude') - expect(startup?.pasteAfterReady).toBe('fix the build\nthen run the tests') + expect(startup?.launchCommand).toContain('fix the build') + expect(startup?.pasteAfterReady).toBeNull() }) it('tells its own pointer apart from a prompt that merely mentions one', () => {