fix(launch-prompt): one host-derived carry rule, receipts proven like the crash guard

- The carried prompt's receipt is delivered only on the agent's hook turn or a fresh
  foreground read that finds the launched agent in front; readiness alone never counts
  and bracketed paste turned off revokes it. An unknown read is reported as unconfirmed.
- Host facts (paired, proves the agent in front) come from describeLaunchHost; a caller
  that cannot paste keeps the prompt on the line as main did instead of refusing.
- Windows keeps any exact line up to cmd's 8,191 cap on the line; only damaged or
  multi-line prompts ride a launch file.
- A launch-file refusal is a "failed without effects" code for agent.launch, and the
  desktop closes the tab it opened for it.
- Launch-file folders carry their owner's pid; the sweep never removes a live owner's.
- The terminal-handle wait keeps waiting while that tab's spawn is still in flight.
This commit is contained in:
Brennan Benson
2026-10-02 02:50:06 -07:00
parent 4635a6406f
commit ea0c65d041
70 changed files with 1045 additions and 345 deletions
@@ -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')}'`)
@@ -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<LaunchedAgentForeground> => {
if (!runtime || typeof args?.id !== 'string' || !isTuiAgent(args.agent)) {
return 'unknown'
}
return runtime.readLaunchedAgentForeground(args.id, args.agent)
}
)
}
+8 -1
View File
@@ -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()
}
})
}
+3
View File
@@ -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)
}
@@ -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()
})
})
@@ -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)
})
})
@@ -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'
}
@@ -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 () => {
@@ -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')
}
@@ -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<string, (event: unknown, response: unknown) => 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()
})
})
@@ -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,
@@ -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}.`)
@@ -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
@@ -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()
@@ -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' })
@@ -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()
})
})
@@ -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
}
@@ -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}.`)
@@ -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<string> {
function waitForHandle(
runtime: OrcaRuntimeService,
tabId: string,
timeoutMs = 5_000
): Promise<string> {
// 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<string>
}
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()
}
})
})
@@ -8,6 +8,31 @@ const MAX_REFUSALS = 64
const refusals = new Map<string, { message: string; at: number }>()
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<string, number>()
/** 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()
@@ -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 })
}
+2
View File
@@ -128,6 +128,8 @@ export type PtyApi = {
}
) => Promise<TerminalProcessInspection>
confirmForegroundProcess: (id: string) => Promise<string | null>
/** 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<boolean>
/** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */
@@ -16,6 +16,11 @@ export const ptyStreamAndSerializationApi = {
ipcRenderer.invoke('pty:inspectProcess', { id, ...options }),
confirmForegroundProcess: (id: string): Promise<string | null> =>
ipcRenderer.invoke('pty:confirmForegroundProcess', { id }),
readLaunchedAgentForeground: (
id: string,
agent: string
): Promise<'agent' | 'shell' | 'unknown'> =>
ipcRenderer.invoke('pty:readLaunchedAgentForeground', { id, agent }),
isCodexOnSharedServer: (id: string): Promise<boolean> =>
ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }),
disableCodexSharedServerAutoStart: (id: string): Promise<boolean> =>
@@ -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 }
@@ -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)
})
},
[
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
})
@@ -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
)
+5
View File
@@ -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."
}
}
}
}
+5
View File
@@ -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."
}
}
}
}
+5
View File
@@ -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."
}
}
}
}
+5
View File
@@ -527,6 +527,11 @@
"message": "Agent は起動しませんでした。プロンプトを渡すファイルを Orca が書き込めませんでした。プロンプトをコピーして、もう一度起動してください。"
}
}
},
"unconfirmed": {
"notice": {
"message": "Agent がプロンプトを受け取ったことを Orca で確認できませんでした。再送する前に Agent を確認してください。"
}
}
}
}
+5
View File
@@ -527,6 +527,11 @@
"message": "에이전트가 시작되지 않았습니다. Orca가 프롬프트를 담을 파일을 쓰지 못했습니다. 프롬프트를 복사한 뒤 다시 시작하세요."
}
}
},
"unconfirmed": {
"notice": {
"message": "에이전트가 프롬프트를 받았는지 Orca가 확인하지 못했습니다. 다시 보내기 전에 에이전트를 확인하세요."
}
}
}
}
+5
View File
@@ -527,6 +527,11 @@
"message": "代理未启动:Orca 无法写入承载你的提示词的文件。请复制提示词并重新启动。"
}
}
},
"unconfirmed": {
"notice": {
"message": "Orca 无法确认代理已收到你的提示词。再次发送前请先检查代理。"
}
}
}
}
@@ -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<typeof setTimeout>) => 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)
})
})
})
+25 -2
View File
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<boolean> {
return new Promise<boolean>((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()
}
}
@@ -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' }))
@@ -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' }))
@@ -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
}))
@@ -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.
@@ -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<string, { id: string }[]>
@@ -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<never> => 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<typeof setTimeout>) => 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')
})
})
@@ -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<typeof useAppStore.getState>
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<boolean> {
}): Promise<LaunchPromptReceipt> {
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())
})
}
+1 -1
View File
@@ -273,7 +273,7 @@ async function sendBracketedPasteToAgent(args: {
}
}
export function waitForAgentDraftInputReadyOnTab(args: {
function waitForAgentDraftInputReadyOnTab(args: {
tabId: string
spawnTimeoutMs: number
readinessTimeoutMs: number
@@ -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({
@@ -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()
@@ -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({
@@ -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
@@ -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
}
@@ -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'
)
}
}
@@ -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()
+18 -3
View File
@@ -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)
})
}
@@ -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<typeof resolveSourceControlLaunchPlatform>[0]['projectRuntime']
launchHostIsPaired: boolean
runtimeEnvironmentId: string | null
}): ReturnType<typeof buildDirectWorkItemAgentStartupPlan> {
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'
})
})
}
@@ -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',
@@ -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 }
@@ -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 =
@@ -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
}
}
@@ -40,6 +40,7 @@ export function createPtyApi(): NonNullable<Partial<PreloadApi>['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),
+46 -5
View File
@@ -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)
})
})
+26 -4
View File
@@ -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-<owner pid>-<random>`: 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 {
+21
View File
@@ -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
})
})
})
+28
View File
@@ -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'
}
}
+2 -2
View File
@@ -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')
+60 -23
View File
@@ -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<Omit<AgentLaunchPromptArgs, 'agent' | 'prompt'>> = {}
) {
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')
+43 -24
View File
@@ -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<A extends CarriedPlanArgs, P extends { launchCommand: string }>(
args: A,
@@ -120,12 +136,13 @@ export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchC
return pasteAfterReady()
}
const lineOrPaste = (): LaunchPromptPlan<P> | 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<P> | 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<A extends CarriedPlanArgs, P extends { launchC
plan && { carry: 'launch-file', plan, launchFile: withQuoting(pointer.launchFile, shell) }
)
}
const readsEnv = mode === 'hermes-query'
if (
text.length > 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 }
+6 -1
View File
@@ -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)
}
+18 -2
View File
@@ -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<AgentLaunchPromptArgs, 'host' | 'canPasteAfterReady'> &
Partial<Pick<AgentLaunchPromptArgs, 'host' | 'canPasteAfterReady'>> & {
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
}
+7 -4
View File
@@ -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()
@@ -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', () => {