fix(agent-launch): make where a launch prompt rides explicit, and delivery mean the agent got it

Round-1 review of the rebuilt step 3.

- One outcome, switched on by every caller. planLaunchPrompt answers none,
  on-line, launch-file or paste-after-ready; only the carried outcomes hold
  the plan to launch, so no caller can read "a plan exists" as "carried".
  buildAgentStartupPlan now builds only a prompt-less launch. The new agent
  tab and work items had dropped a prompt an agent could not take in a file
  (any agent but Claude and Codex) and reported it delivered; they now paste
  it. The composer paths, background sessions, agent.launch, agentSession.create
  and phone quick commands switch on the same outcome.
- One rule for hosts that type the line raw. A Windows host or a paired Orca
  keeps a line to #24257's 512-byte typed budget; anything longer, multi-line
  or damaged by the shell rides a launch file for Claude and Codex and is
  pasted for others. A paired host is one predicate (launchHostIsPaired).
- Delivered means the agent got it. A prompt the new tab's line carried
  resolves its delivery when the agent's own hook reports a turn or the
  agent shows its composer, and false when the PTY never spawns (a refused
  launch file) or exits first, so callers that resolve review threads or
  mark notes delivered wait for that, not for the tab.
- Daemon protocol v41 writes launch files. A spawn routed to an older daemon
  is refused with the launch-file reason instead of typing a pointer to a
  file nobody wrote.
- A refused launch file or WSL staged line reaches agent.launch as its reason
  instead of a 10 s handle timeout, and hands the prompt back with Copy prompt
  for a staged line too.
- A WSL distro is probed only when the spawn needs its directory, and a
  failed probe is remembered for 30 s.
- Every hook status row shows the prompt a launch file carries instead of its
  pointer sentence.
This commit is contained in:
Brennan Benson
2026-10-02 01:30:25 -07:00
parent de94a21fca
commit d3fdcac605
120 changed files with 2095 additions and 1101 deletions
@@ -272,7 +272,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
})
it('names the branch from the prompt a launch file carried, when main kept it', async () => {
const { deps, onRenamed } = makeDeps({ getLaunchFilePrompt: () => 'Fix auth from note #1' })
const { deps, onRenamed } = makeDeps({ showLaunchPrompt: () => 'Fix auth from note #1' })
await maybeAutoRenameBranchOnFirstWork(
workingEvent({ prompt: buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') }),
deps
@@ -68,8 +68,8 @@ export type FirstWorkBranchRenameDeps = {
resolveWorktreeIdForTab: (tabId: string) => string | undefined
/** Invalidate caches + notify the renderer so the new branch name surfaces. */
onRenamed: (repoId: string) => void
/** The prompt a launch file carried for this pane, when its hook reports only the pointer. */
getLaunchFilePrompt?: (paneKey: string) => string | undefined
/** The prompt Orca shows for a pane's reported one (`launchPromptShownForPane`). */
showLaunchPrompt?: (paneKey: string, prompt: string) => string
}
// inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later).
@@ -121,10 +121,10 @@ export async function maybeAutoRenameBranchOnFirstWork(
// Why the pointer: a launch that carried its prompt in a file names only that file here, which
// would name the branch after "read the task file". Its real prompt, when main kept it, names it
// instead; otherwise (a worker brief) a later prompt of the user's own can.
const prompt =
hookPrompt && isLaunchFilePointer(hookPrompt)
? deps.getLaunchFilePrompt?.(event.paneKey)?.trim()
: hookPrompt
const shown = hookPrompt
? (deps.showLaunchPrompt?.(event.paneKey, hookPrompt) ?? hookPrompt).trim()
: undefined
const prompt = shown && !isLaunchFilePointer(shown) ? shown : undefined
if (!prompt) {
return
}
@@ -1,4 +1,4 @@
import { launchFilePromptForPane } from './launch-file-prompt-by-pane'
import { launchPromptShownForPane } from './launch-file-prompt-by-pane'
import { existsSync } from 'node:fs'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
@@ -22,7 +22,7 @@ export function firstWorkRenameDeps(
>
): FirstWorkBranchRenameDeps {
return {
getLaunchFilePrompt: launchFilePromptForPane,
showLaunchPrompt: launchPromptShownForPane,
getSettings: () => store.getSettings(),
getRepo: (repoId) => store.getRepo(repoId),
getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(),
@@ -1,4 +1,4 @@
import type { LaunchFile } from '../../shared/launch-prompt-file'
import { isLaunchFilePointer, type LaunchFile } from '../../shared/launch-prompt-file'
// Why bounded: an entry outlives its launch until newer ones push it out; the rename reads only a
// prompt's opening, and a pane's rename runs within minutes of its launch.
@@ -7,9 +7,9 @@ const KEPT_CHARS = 4_000
const promptByPane = new Map<string, string>()
/**
* Keeps a launch file's prompt for the pane it launched, for the first-work rename: the agent's hook
* reports only the pointer to the file. A sensitive file (a worker brief with its capability) is
* never kept.
* Keeps a launch file's prompt for the pane it launched: the agent's hook reports only the pointer
* to the file, and Orca shows the prompt instead (`launchPromptShownForPane`). A sensitive file (a
* worker brief with its capability) is never kept.
*/
export function rememberLaunchFilePrompt(
paneKey: string,
@@ -28,6 +28,8 @@ export function rememberLaunchFilePrompt(
}
}
export function launchFilePromptForPane(paneKey: string): string | undefined {
return promptByPane.get(paneKey)
/** The one place Orca reads a pane's reported prompt: a launch file's pointer reads as the prompt
* it points at, when this host kept it. */
export function launchPromptShownForPane(paneKey: string, prompt: string): string {
return isLaunchFilePointer(prompt) ? (promptByPane.get(paneKey) ?? prompt) : prompt
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file'
import { AgentHookServer } from './server'
import { rememberLaunchFilePrompt } from './launch-file-prompt-by-pane'
const LAUNCHED = makePaneKey('tab-launched', '55555555-5555-4555-8555-555555555555')
const POINTER = buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md')
describe('a status row whose agent reports its launch file’s pointer', () => {
// Why: the agent's own hook sees only the pointer sentence; every Orca reader of the row (status
// row, dashboard, phone) must see the prompt the user wrote.
it('shows the prompt the file carries', () => {
rememberLaunchFilePrompt(LAUNCHED, carryInLaunchFile('fix the flaky test', false).launchFile)
const server = new AgentHookServer()
server.ingestTerminalStatus({
paneKey: LAUNCHED,
tabId: 'tab-launched',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: POINTER }
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: LAUNCHED, prompt: 'fix the flaky test' })
])
})
it('leaves a sensitive file’s pointer, whose content is never kept', () => {
const pane = makePaneKey('tab-sensitive', '66666666-6666-4666-8666-666666666666')
rememberLaunchFilePrompt(pane, carryInLaunchFile('token dcap_secret', true).launchFile)
const server = new AgentHookServer()
server.ingestTerminalStatus({
paneKey: pane,
tabId: 'tab-sensitive',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: POINTER }
})
expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ prompt: POINTER })])
})
})
@@ -19,6 +19,7 @@ import {
import { isStaleGrokTurnEnd } from './server-grok-status-rules'
import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch'
import { AgentHookServerStatusApplication } from './server-status-application'
import { launchPromptShownForPane } from '../launch-file-prompt-by-pane'
export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication {
protected applyNormalizedStatus(
@@ -28,6 +29,12 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
observedAt?: number,
mutationBefore?: EnrichedAgentHookEventPayload
): EnrichedAgentHookEventPayload | undefined {
// Why here: every status row (local, relayed, replayed) passes through, so no reader of the
// prompt (status row, dashboard, mobile) shows a launch file's pointer instead of the prompt.
const prompt = launchPromptShownForPane(incoming.paneKey, incoming.payload.prompt)
if (prompt !== incoming.payload.prompt) {
incoming = { ...incoming, payload: { ...incoming.payload, prompt } }
}
const transitioned = transitionHookPresence(
incoming,
this.state.lastStatusByPaneKey.get(incoming.paneKey)
@@ -4,7 +4,7 @@
# mobile-followup the phone's session-tab launch; attribution is a separate follow-up
# The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed.
src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes
src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 2 attributes
src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes
src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume
src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup
@@ -22,9 +22,9 @@ const LISTED: ReadonlyMap<string, { calls: number; decision: string }> = new Map
})
)
// planStartupWithLaunchPrompt wraps buildAgentStartupPlan in shared/, which this scan does not reach.
// planLaunchPrompt plans every prompted launch; buildAgentStartupPlan only a prompt-less one.
const BUILDER_CALL =
/\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planStartupWithLaunchPrompt)\s*\(/g
/\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planLaunchPrompt)\s*\(/g
const DECIDE =
'Decide attribution: a fresh agent the host builds must carry ' +
@@ -21,7 +21,7 @@ export type CreateOrAttachRequest = {
envToDelete?: string[]
command?: string
startupCommandDelivery?: StartupCommandDelivery
/** v39+: written by the daemon before it types `command`. */
/** v41+ (`LAUNCH_FILE_DAEMON_PROTOCOL_VERSION`): written by the daemon before it types `command`. */
launchFile?: LaunchFile
/** Where the daemon writes a WSL session's staged line and launch file; main resolves it. */
wslLaunchDirectory?: WslLaunchDirectory
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(40)
expect(PROTOCOL_VERSION).toBe(41)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -23,5 +23,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(37)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(38)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(39)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(40)
})
})
@@ -0,0 +1,78 @@
import './mock-descendant-sweep'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import { DaemonClient } from './client'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { LAUNCH_FILE_DAEMON_PROTOCOL_VERSION, PROTOCOL_VERSION } from './daemon-protocol-version'
import type { DaemonServer } from './daemon-server'
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
import { carryInLaunchFile } from '../../shared/launch-prompt-file'
describe('a launch file sent to a terminal daemon', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer
let adapter: DaemonPtyAdapter
beforeEach(async () => {
const harness = await startDaemonAdapterHarness(() => createMockSubprocess())
;({ dir, socketPath, tokenPath, server, adapter } = harness)
})
afterEach(async () => {
vi.restoreAllMocks()
adapter?.dispose()
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
it('is a protocol the current daemon speaks', () => {
expect(PROTOCOL_VERSION).toBeGreaterThanOrEqual(LAUNCH_FILE_DAEMON_PROTOCOL_VERSION)
})
// Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote.
it('is refused, with the reason, by a daemon older than launch files', async () => {
vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue()
const request = vi.spyOn(DaemonClient.prototype, 'request')
const legacy = new DaemonPtyAdapter({
socketPath,
tokenPath,
protocolVersion: LAUNCH_FILE_DAEMON_PROTOCOL_VERSION - 1
})
const { prompt, launchFile } = carryInLaunchFile('the whole task', false)
try {
await expect(
legacy.spawn({
sessionId: 'legacy-launch',
cols: 80,
rows: 24,
command: `claude '${prompt}'`,
launchAgent: 'claude',
launchFile
})
).rejects.toThrow(/launch_file_unavailable/)
expect(request).not.toHaveBeenCalledWith(
'createOrAttach',
expect.anything(),
expect.anything()
)
} finally {
legacy.dispose()
}
})
it('is sent to the current daemon, which writes it', async () => {
const request = vi.spyOn(DaemonClient.prototype, 'request')
const { prompt, launchFile } = carryInLaunchFile('the whole task', false)
await adapter.spawn({
cols: 80,
rows: 24,
command: `claude '${prompt}'`,
launchAgent: 'claude',
launchFile
})
const createCall = request.mock.calls.find(([method]) => method === 'createOrAttach')
expect(createCall?.[1]).toMatchObject({ launchFile: { placeholder: launchFile.placeholder } })
})
})
@@ -22,7 +22,7 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(40)
expect(PROTOCOL_VERSION).toBe(41)
expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38)
expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37)
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
@@ -38,7 +38,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 39 }, (_, index) => index + 1)
Array.from({ length: 40 }, (_, index) => index + 1)
)
})
+5 -3
View File
@@ -1,6 +1,8 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v40 stages long startup commands as sourced scripts; older owners stay attachable.
export const PROTOCOL_VERSION = 40
// v41 writes an agent's launch file before it types the line naming it; older owners stay attachable.
export const PROTOCOL_VERSION = 41
// Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote.
export const LAUNCH_FILE_DAEMON_PROTOCOL_VERSION = 41
// Why: older daemons reject `setColorQueryReplyColors` as an unknown request type.
export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38
export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37
@@ -33,7 +35,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39
28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40
] as const
export function supportsColorQueryReplyColors(protocolVersion: number): boolean {
+3 -3
View File
@@ -18,7 +18,7 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { resolveWslSessionContext } from './wsl-session-context'
import { resolveWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution'
import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
@@ -194,8 +194,8 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
}
await this.ensureConnected()
const wslLaunchDirectory =
!attachOnly && opts.command ? await resolveWslLaunchDirectory(wslDistro) : undefined
const wslProbe = resolveSpawnWslLaunchDirectory(wslDistro, opts)
const wslLaunchDirectory = wslProbe ? await wslProbe : undefined
// Pins the daemon's spawn to the distro the daemon writes the line and file into.
if (wslLaunchDirectory) {
opts = { ...opts, wslLaunchDirectory, terminalWindowsWslDistro: wslLaunchDirectory.distro }
+14 -1
View File
@@ -7,7 +7,11 @@ import {
type SnapshotCheckpointResult
} from './daemon-pty-runtime-state'
import { isDaemonGoneError } from './daemon-endpoint-errors'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
import {
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
LAUNCH_FILE_DAEMON_PROTOCOL_VERSION
} from './daemon-protocol-version'
import { LaunchFileUnavailableError } from '../../shared/launch-file-writing'
import type { ColdRestoreInfo } from './history-reader'
import { NdjsonLineTooLongError } from './ndjson'
import {
@@ -106,6 +110,15 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState {
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
// Why refuse: an older daemon (a same-id respawn routed to it) would drop the file and type
// a line naming it. The refusal reaches the user with the prompt to copy.
if (
!context.attachOnly &&
opts.launchFile &&
this.protocolVersion < LAUNCH_FILE_DAEMON_PROTOCOL_VERSION
) {
throw new LaunchFileUnavailableError("this terminal's daemon is from an older Orca")
}
const payload = {
sessionId: context.sessionId,
cols: context.effectiveCols,
@@ -9,7 +9,7 @@ import {
type DaemonAdapterHarness,
type SpawnSubprocess
} from './daemon-pty-adapter-test-harness'
import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file'
import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file'
const itOnPosix = process.platform === 'win32' ? it.skip : it
@@ -141,7 +141,7 @@ describe('DaemonPtyAdapter startup delivery', () => {
vi.stubEnv('TMPDIR', stagingDir)
nextShellPath = '/bin/zsh'
try {
const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true })
const { prompt, launchFile } = carryInLaunchFile('secret brief', true)
await adapter.spawn({
cols: 80,
rows: 24,
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite
import { createMockSubprocess } from './daemon-pty-adapter-test-harness'
import { TerminalHost } from './terminal-host'
import type { TerminalHostOptions } from './terminal-host-options'
import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file'
import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file'
import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory'
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
@@ -33,7 +33,7 @@ describe('a daemon WSL session with a launch file', () => {
})
function create(wslLaunchDirectory: WslLaunchDirectory | undefined) {
const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true })
const { prompt, launchFile } = carryInLaunchFile('secret brief', true)
return host.createOrAttach({
sessionId: 'wsl-session',
cols: 80,
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file'
import { takeTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, args: unknown) => Promise<unknown>>(),
runSpawn: vi.fn()
}))
vi.mock('../../pty-host-bindings', () => ({
getPtyIpc: () => ({
handle: (channel: string, handler: (event: unknown, args: unknown) => Promise<unknown>) =>
mocks.handlers.set(channel, handler)
})
}))
vi.mock('./spawn-run', () => ({ runPtyIpcSpawn: mocks.runSpawn }))
import { installPtySpawnIpcHandler } from './spawn'
describe('a pane spawn refused for what carries its prompt', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only getLocalPtyStartupPromise before delegating to the mocked runner.
installPtySpawnIpcHandler({ getLocalPtyStartupPromise: () => null } as unknown as PtySpawnIpcDeps)
const spawn = (args: Partial<PtySpawnIpcArgs>): Promise<unknown> =>
mocks.handlers.get('pty:spawn')!({}, { cols: 80, rows: 24, ...args })
it('records the refusal for its tab, for a create waiting on that tab’s handle', async () => {
const refusal = describeLaunchFileUnavailable('disk full')
mocks.runSpawn.mockRejectedValueOnce(new Error(refusal))
await expect(spawn({ tabId: 'tab-refused' })).rejects.toThrow(refusal)
expect(takeTerminalLaunchRefusal('tab-refused')).toBe(refusal)
})
it('records nothing for any other spawn failure', async () => {
mocks.runSpawn.mockRejectedValueOnce(new Error('spawn ENOENT'))
await expect(spawn({ tabId: 'tab-other' })).rejects.toThrow('spawn ENOENT')
expect(takeTerminalLaunchRefusal('tab-other')).toBeUndefined()
})
})
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { buildPtyIpcSpawnOptions } from './spawn-options'
import { launchFilePromptForPane } from '../../../agent-hooks/launch-file-prompt-by-pane'
import { launchPromptShownForPane } from '../../../agent-hooks/launch-file-prompt-by-pane'
import { buildLaunchFilePointer } from '../../../../shared/launch-prompt-file'
import { createPtyIpcSpawnState } from './spawn-state'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
@@ -59,8 +60,8 @@ describe('renderer pty spawn: launch file', () => {
expect(options.launchFile).toBeUndefined()
})
// Why: the agent's hook reports only the pointer, so the first-work rename reads this instead.
it('keeps the prompt a launch file carries for its pane, never a sensitive one', async () => {
// Why: the agent's hook reports only the pointer; Orca shows the prompt it points at instead.
it('shows the prompt a launch file carries for its pane, never a sensitive one', async () => {
await spawnOptionsFor(
{ cols: 80, rows: 24, command, launchFile: LAUNCH_FILE },
command,
@@ -76,7 +77,9 @@ describe('renderer pty spawn: launch file', () => {
command,
'tab-b:leaf-b'
)
expect(launchFilePromptForPane('tab-a:leaf-a')).toBe('the whole task')
expect(launchFilePromptForPane('tab-b:leaf-b')).toBeUndefined()
const pointer = buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md')
expect(launchPromptShownForPane('tab-a:leaf-a', pointer)).toBe('the whole task')
expect(launchPromptShownForPane('tab-b:leaf-b', pointer)).toBe(pointer)
expect(launchPromptShownForPane('tab-a:leaf-a', 'a later prompt')).toBe('a later prompt')
})
})
+12 -1
View File
@@ -1,6 +1,8 @@
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'
export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void {
const ipcMain = getPtyIpc()
@@ -11,6 +13,15 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void {
if (startupPromise) {
await startupPromise
}
return runPtyIpcSpawn(deps, args)
try {
return await runPtyIpcSpawn(deps, args)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// Why: a runtime create waits on this tab's handle; the refusal is the answer it waits for.
if (args.tabId && isLaunchFileUnavailableMessage(message)) {
recordTerminalLaunchRefusal(args.tabId, message)
}
throw error
}
})
}
@@ -118,7 +118,7 @@ vi.mock('../shell-prompt-readiness-probe', () => ({
import { LocalPtyProvider } from './local-pty-provider'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command'
import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file'
import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file'
import {
applyLocalPtyProviderMockDefaults,
createLocalPtyMockProcess,
@@ -273,7 +273,7 @@ describe('LocalPtyProvider', () => {
vi.useFakeTimers()
try {
process.env.SHELL = '/bin/sh'
const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true })
const { prompt, launchFile } = carryInLaunchFile('secret brief', true)
await provider.spawn({ cols: 80, rows: 24, command: `claude '${prompt}'`, launchFile })
@@ -4,11 +4,11 @@ import type * as SpawnStateModule from './local-pty-spawn-state'
import { ptyShutdownOperations, type PtyShutdownOperation } from './local-pty-provider-state'
import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory'
const { resolveWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({
resolveWslLaunchDirectory: vi.fn(),
const { resolveSpawnWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({
resolveSpawnWslLaunchDirectory: vi.fn(),
reattachLocalPty: vi.fn()
}))
vi.mock('./wsl-launch-directory-resolution', () => ({ resolveWslLaunchDirectory }))
vi.mock('./wsl-launch-directory-resolution', () => ({ resolveSpawnWslLaunchDirectory }))
vi.mock('./local-pty-launch-plan', async (importOriginal) => ({
...(await importOriginal<typeof LaunchPlanModule>()),
resolveLocalPtyWslDistro: () => 'Ubuntu'
@@ -30,7 +30,7 @@ describe('a WSL respawn racing a shutdown of the same session id', () => {
// starts during it would otherwise be missed and race the new PTY.
it('waits for a shutdown that began while the distro directory was being resolved', async () => {
let resolveDirectory!: (directory: WslLaunchDirectory | undefined) => void
resolveWslLaunchDirectory.mockReturnValue(
resolveSpawnWslLaunchDirectory.mockReturnValue(
new Promise((resolve) => {
resolveDirectory = resolve
})
@@ -40,7 +40,9 @@ describe('a WSL respawn racing a shutdown of the same session id', () => {
{ cols: 80, rows: 24, sessionId: 'wsl-session', command: 'claude' },
() => ({})
)
await vi.waitFor(() => expect(resolveWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu'))
await vi.waitFor(() =>
expect(resolveSpawnWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu', expect.anything())
)
let finishShutdown!: () => void
const shutdown: PtyShutdownOperation = {
+3 -2
View File
@@ -30,7 +30,7 @@ import {
writeSpawnLaunchFile,
type WrittenLaunchFile
} from '../../shared/launch-file-writing'
import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution'
import { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution'
export async function spawnLocalPty(
args: PtySpawnOptions,
@@ -40,7 +40,8 @@ export async function spawnLocalPty(
const wslDistro =
args.command && !args.attachOnly ? resolveLocalPtyWslDistro(args, getOptions) : undefined
// Why before the shutdown check: no await may sit between it and this spawn's registration.
const wslLaunchDirectory = wslDistro ? await resolveWslLaunchDirectory(wslDistro) : undefined
const wslProbe = resolveSpawnWslLaunchDirectory(wslDistro, args)
const wslLaunchDirectory = wslProbe ? await wslProbe : undefined
if (reattachId) {
const pendingShutdown = ptyShutdownOperations.get(reattachId)
if (pendingShutdown) {
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import type { ClientChannel, PseudoTtyOptions } from 'ssh2'
import { SshPlainShellPtyProvider } from './ssh-plain-shell-pty-provider'
import { carryInLaunchFile } from '../../shared/launch-prompt-file'
class FakeShellChannel extends EventEmitter {
readonly stderr = new EventEmitter()
@@ -147,6 +148,11 @@ describe('SshPlainShellPtyProvider', () => {
await expect(provider.spawn({ cols: 80, rows: 24, attachOnly: true })).rejects.toThrow(
'(no_runtime)'
)
// Why: nothing here writes a launch file, so its line would name a file that is not there.
const { prompt, launchFile } = carryInLaunchFile('the whole task', false)
await expect(
provider.spawn({ cols: 80, rows: 24, command: `claude '${prompt}'`, launchFile })
).rejects.toThrow('needs the Orca remote server')
expect(opened).toEqual([])
})
})
@@ -72,7 +72,13 @@ export class SshPlainShellPtyProvider implements IPtyProvider {
if (this.disposed) {
throw new Error('SSH connection is not active')
}
if (opts.agentSessionEnsure || opts.agentSessionCreateOperationId || opts.launchAgent) {
// Why launchFile too: no host here writes it, so the line would name a file that is not there.
if (
opts.agentSessionEnsure ||
opts.agentSessionCreateOperationId ||
opts.launchAgent ||
opts.launchFile
) {
throw new PlainSshUnsupportedError('Launching agents', this.mode)
}
if (opts.attachOnly) {
@@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty-launch-plan'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
import type { PtySpawnOptions } from './types'
import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution'
import {
resolveSpawnWslLaunchDirectory,
resolveWslLaunchDirectory,
WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS
} from './wsl-launch-directory-resolution'
import type * as WslModule from '../wsl'
const { runWslProcess } = vi.hoisted(() => ({ runWslProcess: vi.fn() }))
@@ -57,13 +61,41 @@ describe('resolveWslLaunchDirectory', () => {
)
})
it('probes a distro once, and asks again only after a failure', async () => {
runWslProcess.mockResolvedValueOnce(answers('', 1))
await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined()
it('probes a distro once, and remembers a failure only for its TTL', async () => {
vi.useFakeTimers()
try {
runWslProcess.mockResolvedValueOnce(answers('', 1))
await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined()
runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n'))
// Why: a broken distro must not cost every spawn a wsl.exe round trip.
await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined()
expect(runWslProcess).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS)
await expect(resolveWslLaunchDirectory('Arch')).resolves.toMatchObject({
shell: '/bin/bash'
})
await resolveWslLaunchDirectory('Arch')
expect(runWslProcess).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('probes only for a spawn that needs the directory', async () => {
runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n'))
await expect(resolveWslLaunchDirectory('Arch')).resolves.toMatchObject({ shell: '/bin/bash' })
await resolveWslLaunchDirectory('Arch')
expect(runWslProcess).toHaveBeenCalledTimes(2)
expect(
resolveSpawnWslLaunchDirectory('Gentoo', { command: 'claude', launchAgent: 'claude' })
).toBeUndefined()
expect(
resolveSpawnWslLaunchDirectory(undefined, { command: 'x'.repeat(600), launchAgent: 'claude' })
).toBeUndefined()
await expect(
resolveSpawnWslLaunchDirectory('Gentoo', {
command: `claude '${'x'.repeat(600)}'`,
launchAgent: 'claude'
})
).resolves.toMatchObject({ distro: 'Gentoo' })
expect(runWslProcess).toHaveBeenCalledTimes(1)
})
it('has none for a spawn outside WSL or a distro whose home cannot be read', async () => {
@@ -1,6 +1,9 @@
import { posix } from 'node:path'
import { quotePosixShell, RESOLVE_WSL_LOGIN_SHELL } from '../../shared/wsl-login-shell-command'
import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory'
import type { LaunchFile } from '../../shared/launch-prompt-file'
import { spawnNeedsWslLaunchDirectory } from '../../shared/launch-file-writing'
import type { TuiAgent } from '../../shared/tui-agent'
import { toWindowsWslUncPath } from '../../shared/wsl-paths'
import { runWslProcess } from '../wsl/wsl-runner'
@@ -8,11 +11,36 @@ const ORCA_CACHE_RELATIVE = '.cache/orca'
const probes = new Map<string, Promise<WslLaunchDirectory | undefined>>()
/** A failed probe is answered from memory this long, so a broken distro does not cost every spawn
* a `wsl.exe` round trip, yet a distro that comes back is found again. */
export const WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS = 30_000
/** The directory for a spawn that needs one (`spawnNeedsWslLaunchDirectory`); no probe, and no
* await for the caller, otherwise. */
export function resolveSpawnWslLaunchDirectory(
distro: string | null | undefined,
spawn: {
command?: string
launchFile?: LaunchFile
launchAgent?: TuiAgent
attachOnly?: boolean
}
): Promise<WslLaunchDirectory | undefined> | undefined {
const needed =
Boolean(distro) &&
spawn.attachOnly !== true &&
spawnNeedsWslLaunchDirectory({
...spawn,
orcaBuiltLine: spawn.launchAgent !== undefined
})
return needed ? resolveWslLaunchDirectory(distro) : undefined
}
/**
* The distro directory a WSL spawn's staged line and launch file are written to, and the login
* shell the pane runs, which decides how a staged line is sourced; undefined when the distro cannot
* be asked, and the write site then refuses a launch that needs one. Probed once per distro, and
* only a success is kept.
* be asked, and the write site then refuses a launch that needs one. Probed once per distro; a
* success is kept, a failure for `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`.
*/
export async function resolveWslLaunchDirectory(
distro: string | null | undefined
@@ -24,7 +52,16 @@ export async function resolveWslLaunchDirectory(
if (!probe) {
probe = probeWslLaunchDirectory(distro)
probes.set(distro, probe)
void probe.then((found) => found ?? probes.delete(distro))
const pending = probe
void pending.then((found) => {
if (!found) {
setTimeout(() => {
if (probes.get(distro) === pending) {
probes.delete(distro)
}
}, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.()
}
})
}
return await probe
}
@@ -2,9 +2,9 @@
* The launch command a terminal create builds when the launch hands it a prompt.
*
* This is the argv half of terminal prompt delivery, and the reason it is worth pinning is that
* its failure mode is silent: `buildAgentStartupPlan` answers a prompt it cannot fold by returning
* a bare command plus a `followupPrompt`, and this resolver returns options, not a live PTY, so a
* dropped `followupPrompt` would spawn the agent with no prompt and no error anywhere.
* its failure mode is silent: `planLaunchPrompt` answers a prompt it cannot fold with a clean plan
* and `paste-after-ready`, and this resolver returns options, not a live PTY, so a dropped paste
* would spawn the agent with no prompt and no error anywhere.
*/
import { describe, expect, it, vi } from 'vitest'
@@ -20,9 +20,12 @@ import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-i
import {
agentPromptRidesLaunchCommand,
buildAgentDraftLaunchPlan,
buildAgentStartupPlan
planLaunchPrompt
} from '../../shared/tui-agent-startup'
import { windowsDraftRefusal } from '../../shared/startup-plan-launch-file'
import {
launchPromptNeedsPasteRefusal,
windowsDraftRefusal
} from '../../shared/launch-prompt-carry'
import { resolveStartupShell } from '../../shared/tui-agent-startup-shell'
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
import type {
@@ -165,31 +168,39 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
...(request.agentArgs !== undefined ? { agentArgs: request.agentArgs } : {}),
sessionOptions: this.toAgentSessionOptions(request.launchPreferences)
})
const startup =
request.promptDelivery === 'draft'
? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' })
: buildAgentStartupPlan({
...startupArgs,
prompt: request.prompt ?? '',
allowEmptyPromptLaunch: true
})
if (!startup) {
const refusal =
request.promptDelivery === 'draft'
? windowsDraftRefusal(
request.prompt ?? '',
resolveStartupShell(startupArgs.platform, startupArgs.shell)
)
: null
throw new Error(refusal ?? 'agent_session_identity_required')
}
if (startup.followupPrompt && agentPromptRidesLaunchCommand(request.agent)) {
// Why: this create has no paste after ready, so a prompt left for one would be dropped.
throw new Error(
`${request.agent} cannot take this prompt on its command line here (too long, private, or ` +
"broken by the host's Windows shell), so the session was not started. Start it without " +
'the prompt and paste the prompt once it opens.'
)
let startup
let launchFile
if (request.promptDelivery === 'draft') {
startup = buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' })
if (!startup) {
const refusal = windowsDraftRefusal(
request.prompt ?? '',
resolveStartupShell(startupArgs.platform, startupArgs.shell)
)
throw new Error(refusal ?? 'agent_session_identity_required')
}
} else {
const planned = planLaunchPrompt({ ...startupArgs, prompt: request.prompt ?? '' })
if (!planned) {
throw new Error('agent_session_identity_required')
}
switch (planned.carry) {
case 'none':
case 'on-line':
startup = planned.plan
break
case 'launch-file':
startup = planned.plan
launchFile = planned.launchFile
break
case 'paste-after-ready':
// Why: a stdin agent's prompt is the session's to submit; an argv agent's would be dropped.
if (agentPromptRidesLaunchCommand(request.agent)) {
throw new Error(launchPromptNeedsPasteRefusal(request.agent, 'session'))
}
startup = planned.cleanPlan
break
}
}
if (caller.signal?.aborted) {
throw new Error('client_disconnected')
@@ -222,9 +233,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
launchAgent: request.agent,
terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol,
startupCommandDelivery: startup.startupCommandDelivery,
...('launchFile' in startup && startup.launchFile
? { launchFile: startup.launchFile }
: {}),
...(launchFile ? { launchFile } : {}),
// A fresh agent this host built; the request has no surface field, so it counts as `unknown`.
telemetry: agentStartedTelemetry(request.agent, undefined),
cwd: startupCwd,
@@ -2,7 +2,6 @@
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 { removeLaunchFile, writeLaunchFile } from '../../shared/launch-file-writing'
export async function createDesktopTerminal(
runtime: OrcaRuntimeWithCreateTerminal,
@@ -26,11 +25,6 @@ export async function createDesktopTerminal(
`--shell ${opts.shellOverride} needs a workspace, because the shell is resolved on the workspace's execution host. No terminal was created.`
)
}
if (launchOpts.launchFile && !workspace?.connectionId) {
// Why: a pane whose host refuses the launch-file write never registers a handle, so this create
// would only time out; failing the same local write here reports the refusal at once.
removeLaunchFile(writeLaunchFile({ launchFile: launchOpts.launchFile }))
}
const worktreeId = workspace?.id
const cwd = workspace
? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd)
@@ -5,7 +5,8 @@ import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import { planLaunchPrompt } from '../../shared/tui-agent-startup'
import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry'
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal {
@@ -47,7 +48,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) {
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const startupPlan = buildAgentStartupPlan({
const planned = planLaunchPrompt({
...resolveAgentStartupPlanInputs({
agent: opts.agent,
settings,
@@ -56,14 +57,25 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
// 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)
}),
prompt: opts.agentPrompt ?? '',
allowEmptyPromptLaunch: true
prompt: opts.agentPrompt ?? ''
})
if (!startupPlan) {
if (!planned) {
throw new Error(`Could not build launch command for ${opts.agent}.`)
}
if (opts.agentPrompt && startupPlan.followupPrompt) {
throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`)
let startupPlan
let launchFile
switch (planned.carry) {
case 'none':
case 'on-line':
startupPlan = planned.plan
break
case 'launch-file':
startupPlan = planned.plan
launchFile = planned.launchFile
break
case 'paste-after-ready':
// Why: a quick command has no paste after ready, so the prompt would be dropped.
throw new Error(launchPromptNeedsPasteRefusal(opts.agent, 'terminal'))
}
return {
command: startupPlan.launchCommand,
@@ -74,7 +86,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
launchConfig: startupPlan.launchConfig,
launchAgent: opts.agent,
startupCommandDelivery: startupPlan.startupCommandDelivery,
...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {})
...(launchFile ? { launchFile } : {})
}
}
}
@@ -19,14 +19,8 @@ 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,
buildAgentStartupPlan
} from '../../shared/tui-agent-startup'
import {
planStartupWithLaunchPrompt,
type LaunchPromptStartupPlan
} from '../../shared/startup-line-prompt-carry'
import { agentPromptRidesLaunchCommand, planLaunchPrompt } from '../../shared/tui-agent-startup'
import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry'
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'
@@ -277,21 +271,12 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
sessionOptions: this.toAgentSessionOptions(opts.launchPreferences)
})
// A caller that wrote its own launch file already passes the pointer to it as the prompt.
const prompt = opts.startupPrompt ?? ''
let planned: LaunchPromptStartupPlan
if (opts.launchFile) {
const plan = buildAgentStartupPlan({
...planInputs,
prompt,
allowEmptyPromptLaunch: true,
launchFile: opts.launchFile
})
planned = { plan, launchFile: plan?.launchFile ?? opts.launchFile }
} else {
planned = planStartupWithLaunchPrompt(planInputs, prompt)
}
const { plan: startupPlan, launchFile } = planned
if (!startupPlan) {
const planned = planLaunchPrompt({
...planInputs,
prompt: opts.startupPrompt ?? '',
...(opts.launchFile ? { launchFile: opts.launchFile } : {})
})
if (!planned) {
// Why: an explicit agent that yields no plan would otherwise spawn a bare
// shell that never reaches agent readiness.
if (opts.startupAgent) {
@@ -299,16 +284,29 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
}
return opts
}
if (opts.startupPrompt?.trim()) {
const carried = startupPlan.followupPrompt === null
if (!carried && !opts.onStartupPromptCarry) {
// Why: this create returns options, not a live PTY, so the prompt would be dropped.
throw new Error(
`${agent} cannot take this prompt on its command line here, so no terminal was created. ` +
'Start the agent without it and paste the prompt once it opens.'
)
}
opts.onStartupPromptCarry?.(carried)
let startupPlan
let launchFile
switch (planned.carry) {
case 'none':
startupPlan = planned.plan
break
case 'on-line':
startupPlan = planned.plan
opts.onStartupPromptCarry?.(true)
break
case 'launch-file':
startupPlan = planned.plan
launchFile = planned.launchFile
opts.onStartupPromptCarry?.(true)
break
case 'paste-after-ready':
if (!opts.onStartupPromptCarry) {
// Why: this create returns options, not a live PTY, so the prompt would be dropped.
throw new Error(launchPromptNeedsPasteRefusal(agent, 'terminal'))
}
startupPlan = planned.cleanPlan
opts.onStartupPromptCarry(false)
break
}
return {
@@ -2,6 +2,7 @@
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 type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types'
@@ -137,26 +138,43 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals
}
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
const stop = (): void => {
clearTimeout(timer)
stopRefusal()
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
}
const timer = setTimeout(() => {
stop()
reject(new Error('Timed out waiting for terminal handle after creation'))
}, timeoutMs)
// Why: a host that refused the spawn's launch file never registers a handle.
const refuse = (message: string): void => {
stop()
reject(new Error(message))
}
const stopRefusal = onTerminalLaunchRefusal((refusedTabId) => {
const message = refusedTabId === tabId ? takeTerminalLaunchRefusal(tabId) : undefined
if (message) {
refuse(message)
}
})
const check = (): void => {
const handle = this.resolveHandleForTab(tabId)
if (handle) {
clearTimeout(timer)
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
stop()
resolve(handle)
}
}
this.graphSyncCallbacks.push(check)
const recorded = takeTerminalLaunchRefusal(tabId)
if (recorded) {
refuse(recorded)
return
}
// Why: graph sync may have fired between the initial check and registration; re-check to avoid a missed wake-up.
check()
})
@@ -34,7 +34,7 @@ describe('OrcaRuntimeService', () => {
agent: 'aider',
agentPrompt: 'Review this diff'
})
).rejects.toThrow('does not support startup prompt quick commands')
).rejects.toThrow('aider takes its prompt only after it starts')
expect(spawn).not.toHaveBeenCalled()
})
@@ -46,10 +46,10 @@ export type TerminalCreateOptions = {
* Initial text for `startupAgent`'s launch command, for an agent whose CLI takes a prompt
* argument. Not a general prompt channel: an agent that takes its text only after start has no
* launch command to carry it, and a caller that sets this for one is refused rather than having
* the prompt silently dropped. It rides that command, directly or as a pointer to a launch file
* (`startup-line-prompt-carry`), unless it needs a launch file the agent is not known to read:
* then the agent starts clean, `onStartupPromptCarry` says so, and the caller pastes it once
* ready. A caller with no such callback is refused instead.
* the prompt silently dropped. It rides that command, directly or as a pointer to a launch file,
* unless `carryLaunchPrompt` says it must be pasted: then the agent starts clean,
* `onStartupPromptCarry` says so, and the caller pastes it once ready. A caller with no such
* callback is refused instead.
*/
startupPrompt?: string
/** Main-internal: whether `startupPrompt` rode the launch command. Called once the plan is built. */
@@ -8,8 +8,13 @@ import { getRepoSshConnectionId } from '../../shared/execution-host'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection'
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import { planStartupWithLaunchPrompt } from '../../shared/startup-line-prompt-carry'
import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
planLaunchPrompt,
type AgentStartupPlan
} from '../../shared/tui-agent-startup'
import type { LaunchFile } from '../../shared/launch-prompt-file'
import {
detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents
@@ -149,15 +154,31 @@ export function buildWorktreeStartupForAgent(
...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}),
sessionOptions: environment.toSessionOptions(environment.launchPreferences)
})
const { plan: startupPlan, launchFile } = planStartupWithLaunchPrompt(
planInputs,
environment.prompt ?? ''
)
if (!startupPlan) {
const planned = planLaunchPrompt({ ...planInputs, prompt: environment.prompt ?? '' })
if (!planned) {
throw new Error(`Could not build launch command for ${agent}.`)
}
if (environment.onPromptCarry && environment.prompt?.trim()) {
environment.onPromptCarry(startupPlan.followupPrompt === null)
let startupPlan: AgentStartupPlan
let launchFile: LaunchFile | undefined
let followup: WorktreeStartupFollowup | undefined
switch (planned.carry) {
case 'none':
startupPlan = planned.plan
break
case 'on-line':
case 'launch-file':
startupPlan = planned.plan
launchFile = planned.carry === 'launch-file' ? planned.launchFile : undefined
environment.onPromptCarry?.(true)
break
case 'paste-after-ready':
startupPlan = planned.cleanPlan
if (environment.onPromptCarry) {
environment.onPromptCarry(false)
} else {
followup = { expectedProcess: startupPlan.expectedProcess, prompt: planned.text }
}
break
}
return {
agent,
@@ -171,13 +192,6 @@ export function buildWorktreeStartupForAgent(
...(launchFile ? { launchFile } : {}),
telemetry: agentStartedTelemetry(agent, environment.launchSource)
},
...(startupPlan.followupPrompt && !environment.onPromptCarry
? {
followup: {
expectedProcess: startupPlan.expectedProcess,
prompt: startupPlan.followupPrompt
}
}
: {})
...(followup ? { followup } : {})
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { recordTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals'
import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
function waitForHandle(runtime: OrcaRuntimeService, tabId: string): 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)
}
describe('a pane spawn the host refused for its launch file', () => {
const refusal = describeLaunchFileUnavailable(
"the WSL distro's home directory could not be reached"
)
it('is taken once, by the tab it refused', () => {
recordTerminalLaunchRefusal('tab-taken', refusal)
expect(takeTerminalLaunchRefusal('tab-other')).toBeUndefined()
expect(takeTerminalLaunchRefusal('tab-taken')).toBe(refusal)
expect(takeTerminalLaunchRefusal('tab-taken')).toBeUndefined()
})
// Why: agent.launch waits on this handle; the refusal is its answer, not a 10 s timeout.
it('ends a create waiting on that tab’s handle with the reason', async () => {
const runtime = new OrcaRuntimeService()
const waiting = waitForHandle(runtime, 'tab-waiting')
recordTerminalLaunchRefusal('tab-waiting', refusal)
await expect(waiting).rejects.toThrow(refusal)
})
it('ends a create that starts waiting after the refusal arrived', async () => {
const runtime = new OrcaRuntimeService()
recordTerminalLaunchRefusal('tab-early', refusal)
await expect(waitForHandle(runtime, 'tab-early')).rejects.toThrow(refusal)
})
})
@@ -0,0 +1,37 @@
/**
* A pane spawn its host refused because what carries the agent's prompt (a launch file, or the
* staged script of a long line) could not be written. Kept by tab so a create waiting on that tab's
* handle reports the reason at once instead of timing out. Entries expire; nothing waits forever.
*/
const REFUSAL_TTL_MS = 60_000
const MAX_REFUSALS = 64
const refusals = new Map<string, { message: string; at: number }>()
const listeners = new Set<(tabId: string, message: string) => void>()
export function recordTerminalLaunchRefusal(tabId: string, message: string): void {
const now = Date.now()
for (const [key, entry] of refusals) {
if (now - entry.at > REFUSAL_TTL_MS || refusals.size >= MAX_REFUSALS) {
refusals.delete(key)
}
}
refusals.set(tabId, { message, at: now })
for (const listener of listeners) {
listener(tabId, message)
}
}
/** The refusal recorded for `tabId`, consumed. */
export function takeTerminalLaunchRefusal(tabId: string): string | undefined {
const entry = refusals.get(tabId)
refusals.delete(tabId)
return entry && Date.now() - entry.at <= REFUSAL_TTL_MS ? entry.message : undefined
}
export function onTerminalLaunchRefusal(
listener: (tabId: string, message: string) => void
): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
@@ -34,7 +34,7 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({
import type { PtyHandler } from './pty-handler'
import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness'
import { buildLaunchFilePointer, planLaunchPrompt } from '../shared/launch-prompt-file'
import { buildLaunchFilePointer, carryInLaunchFile } from '../shared/launch-prompt-file'
import type { MockDispatcher } from './pty-handler-test-harness'
const describePosix = process.platform === 'win32' ? describe.skip : describe
@@ -114,7 +114,7 @@ describePosix('relay startup command staging', () => {
})
it('writes a launch file before typing the line that names it, and removes it on exit', async () => {
const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true })
const { prompt, launchFile } = carryInLaunchFile('secret brief', true)
await dispatcher.callRequest('pty.spawn', {
command: `claude '${prompt}'`,
commandDelivery: 'provider',
@@ -16,6 +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
}
export function buildSourceControlAgentDeliveryPlan({
@@ -26,7 +27,8 @@ export function buildSourceControlAgentDeliveryPlan({
detectedAgents,
connectionUnavailable,
launchPlatform,
isRemote
isRemote,
launchHostIsPaired
}: BuildSourceControlAgentDeliveryPlanArgs): SourceControlAgentActionDeliveryPlanState {
if (connectionUnavailable) {
return buildSourceControlAgentConnectionErrorPlan()
@@ -50,7 +52,8 @@ export function buildSourceControlAgentDeliveryPlan({
cmdOverrides: settings?.agentCmdOverrides,
terminalWindowsShell: settings?.terminalWindowsShell,
platform: launchPlatform,
isRemote
isRemote,
launchHostIsPaired
})
if (!result.ok) {
return { status: 'error', error: result.error }
@@ -9,6 +9,9 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
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'
import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support'
@@ -116,7 +119,10 @@ export function useSourceControlAgentActionStart({
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform,
isRemote
isRemote,
launchHostIsPaired: launchHostIsPaired(
getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
)
})
},
[
@@ -128,7 +134,8 @@ export function useSourceControlAgentActionStart({
refreshDetectedAgents,
selectedAgent,
launchPlatform,
isRemote
isRemote,
worktreeId
]
)
@@ -62,7 +62,6 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}),
...(draftLaunchPlan.startupCommandDelivery
@@ -311,7 +311,6 @@ describe('submitFolderWorkspaceCreate', () => {
startup: expect.objectContaining({
agent: 'codex',
launchCommand: 'codex',
followupPrompt: null,
draftPrompt: `Review this before starting\n\n${linkedWorkItem.url}`
})
})
@@ -387,7 +386,7 @@ describe('submitFolderWorkspaceCreate', () => {
startup: expect.objectContaining({
agent: 'aider',
launchCommand: 'aider',
followupPrompt: 'Fix the failing folder prompt flow'
pastePromptAfterReady: 'Fix the failing folder prompt flow'
})
})
})
@@ -1,7 +1,7 @@
import { ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace'
import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { planLaunchPrompt } from '@/lib/tui-agent-startup'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
@@ -21,7 +21,11 @@ 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 { launchHostWritesLaunchFile } from '@/lib/launch-file-host'
import { launchHostIsPaired } from '@/lib/launch-file-host'
import {
composerAgentStartupPlan,
type ComposerAgentStartupPlan
} from '@/lib/composer-agent-startup-plan'
import {
buildFolderWorkspaceLinkedStartupPlan,
getFolderWorkspaceAgentLaunchPlatform,
@@ -99,7 +103,7 @@ export async function submitFolderWorkspaceCreate({
isRemote: launchIsRemote,
terminalWindowsShell
})
const startupPlan =
const startupPlan: ComposerAgentStartupPlan | null =
quickAgent && linkedWorkItem
? buildFolderWorkspaceLinkedStartupPlan({
agent: quickAgent,
@@ -114,21 +118,21 @@ export async function submitFolderWorkspaceCreate({
isRemote: launchIsRemote
})
: quickAgent
? buildAgentStartupPlan({
agent: quickAgent,
prompt: note,
cmdOverrides: agentCmdOverrides ?? {},
agentArgs,
agentEnv,
sessionOptions,
platform: launchPlatform,
shell: launchShell,
isRemote: launchIsRemote,
hostWritesLaunchFile: launchHostWritesLaunchFile({
activeRuntimeEnvironmentId: runtimeEnvironmentId
? composerAgentStartupPlan(
planLaunchPrompt({
agent: quickAgent,
prompt: note,
cmdOverrides: agentCmdOverrides ?? {},
agentArgs,
agentEnv,
sessionOptions,
platform: launchPlatform,
shell: launchShell,
isRemote: launchIsRemote,
launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId)
}),
allowEmptyPromptLaunch: true
})
note
)
: null
// Why: the argv-prefill plan carries the draft inside `launchCommand`, so
// `startupPlan.draftPrompt` alone can't tell whether this launch has one.
@@ -194,6 +198,7 @@ export async function submitFolderWorkspaceCreate({
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}),
...(startupPlan.launchPrompt ? { launchPrompt: startupPlan.launchPrompt } : {}),
telemetry: {
agent_kind: tuiAgentToAgentKind(quickAgent),
launch_source: launchSource,
@@ -246,7 +251,7 @@ export async function submitFolderWorkspaceCreate({
if (
!structuredLaunchAccepted &&
startupPlan &&
(startupPlan.followupPrompt || startupPlan.draftPrompt) &&
(startupPlan.pastePromptAfterReady || startupPlan.draftPrompt) &&
activation !== false
) {
void ensureAgentStartupInTerminal({
@@ -191,11 +191,13 @@ function handleConnectError(
if (message.includes('was explicitly killed')) {
return undefined
}
// Why the transport's file: the spawn sends it unless the connect call named its own command.
const { launchFile } = context.transportOptions
if (launchFile && !options.command && isLaunchFileUnavailableMessage(message)) {
// Why the transport's prompt: the spawn carries it unless the connect call named its own command.
// The host refuses a launch file, or a WSL line it cannot stage, with the same code.
const { launchFile, launchPrompt } = context.transportOptions
const refusedPrompt = launchFile?.content ?? launchPrompt
if (refusedPrompt && !options.command && isLaunchFileUnavailableMessage(message)) {
// The pane gets no shell; the notice holds the only copy of the prompt.
showAgentLaunchNotStartedNotice({ prompt: launchFile.content })
showAgentLaunchNotStartedNotice({ prompt: refusedPrompt })
}
if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) {
// Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a
@@ -27,6 +27,8 @@ export type PtyPaneStartup = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
launchFile?: LaunchFile
/** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */
launchPrompt?: string
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
@@ -0,0 +1,18 @@
import type { ConnectPanePtySession } from './connect-pane-pty-session'
/** The startup command a pane spawn sends, with the launch file and prompt it carries; none when
* the startup is delivered by terminal paste instead. */
export function paneStartupCommandOptions(session: ConnectPanePtySession): {
command?: string
launchFile?: NonNullable<ConnectPanePtySession['paneStartup']>['launchFile']
launchPrompt?: string
} {
if (session.shouldDeliverStartupViaTerminalPaste) {
return {}
}
return {
command: session.paneStartup?.command,
launchFile: session.paneStartup?.launchFile,
launchPrompt: session.paneStartup?.launchPrompt
}
}
@@ -25,6 +25,7 @@ import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention'
import { buffersInputOnlyForSshReattach } from './ssh-reattach-input-buffering'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { paneStartupCommandOptions } from './pane-startup-command-options'
import { resolveTerminalInlineImagesEnabled } from '../../../../../shared/terminal-inline-images-settings'
/** Transport creation, terminal capability replies, viewport claims, and undeliverable-input recovery. */
@@ -62,9 +63,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void {
: {}),
env: session.paneEnv,
...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}),
...(session.shouldDeliverStartupViaTerminalPaste
? {}
: { command: session.paneStartup?.command, launchFile: session.paneStartup?.launchFile }),
...paneStartupCommandOptions(session),
...(session.shouldUseProviderSshStartupDelivery
? { commandDelivery: 'provider' as const }
: {}),
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness'
import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file'
const mocks = vi.hoisted(() => ({ showNotStarted: vi.fn() }))
vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({
showAgentLaunchNotStartedNotice: mocks.showNotStarted
}))
describe('a pane spawn the host refused for what carries its prompt', () => {
const originalWindow = globalThis.window
beforeEach(() => {
vi.resetModules()
mocks.showNotStarted.mockReset()
installIpcPtyWindow(originalWindow, { data: () => {}, exit: () => {} })
})
afterEach(() => {
restorePtySpecWindow(originalWindow)
})
// Why: a WSL launch with no launch file still needs its staged line written into the distro.
it('hands back a staged line’s prompt to copy, as it does a launch file’s', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(
new Error(describeLaunchFileUnavailable('unreachable', 'staged-line'))
)
await createIpcPtyTransport({
command: "claude 'a long prompt'",
launchPrompt: 'a long prompt'
}).connect({ url: '', callbacks: { onError: vi.fn() } })
expect(mocks.showNotStarted).toHaveBeenCalledWith({ prompt: 'a long prompt' })
})
it('stays quiet for any other spawn failure', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error('spawn ENOENT'))
await createIpcPtyTransport({ command: 'claude', launchPrompt: 'fix it' }).connect({
url: '',
callbacks: { onError: vi.fn() }
})
expect(mocks.showNotStarted).not.toHaveBeenCalled()
})
})
@@ -270,6 +270,8 @@ export type IpcPtyTransportOptions = {
startupCommandDelivery?: StartupCommandDelivery
/** Local spawns only: a remote runtime writes no file the renderer names. */
launchFile?: LaunchFile
/** See `PtyPaneStartup.launchPrompt`. */
launchPrompt?: string
connectionId?: string | null
executionHostId?: ExecutionHostId | null
worktreeId?: string
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { planLaunchPrompt } from '@/lib/tui-agent-startup'
import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan'
import { buildFullCreationStartup } from './full-creation-startup'
import { tuiAgentToAgentKind } from '../../../../shared/agent-kind'
@@ -7,13 +8,17 @@ describe('the full composer’s renderer-spawned startup', () => {
// Why: SSH repos, folder repos, repos with default tabs and a failed backend spawn all take this
// path; without the file the agent is pointed at nothing and the prompt is lost.
it('carries the launch file its command points at', () => {
const startupPlan = buildAgentStartupPlan({
agent: 'claude',
prompt: 'x'.repeat(200_000),
cmdOverrides: {},
platform: 'linux',
isRemote: true
})
const prompt = 'x'.repeat(200_000)
const startupPlan = composerAgentStartupPlan(
planLaunchPrompt({
agent: 'claude',
prompt,
cmdOverrides: {},
platform: 'linux',
isRemote: true
}),
prompt
)
const startup = buildFullCreationStartup({
startupPlan,
backendSpawnedStartup: false,
@@ -29,5 +34,7 @@ describe('the full composer’s renderer-spawned startup', () => {
expect(startupPlan?.launchFile).toBeDefined()
expect(startup?.launchFile).toEqual(startupPlan?.launchFile)
expect(startup?.command).toContain(startupPlan?.launchFile?.placeholder)
// Handed back to copy if the host refuses the spawn.
expect(startup?.launchPrompt).toBe(prompt)
})
})
@@ -1,9 +1,9 @@
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan'
import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload'
export function buildFullCreationStartup(args: {
startupPlan: AgentStartupPlan | null
startupPlan: ComposerAgentStartupPlan | null
backendSpawnedStartup: boolean
agent: TuiAgent
shouldSeedInitialAgentStatus: boolean
@@ -25,6 +25,7 @@ export function buildFullCreationStartup(args: {
: {}),
// Why: the command points at this file; without it the agent is told to read nothing.
...(args.startupPlan.launchFile ? { launchFile: args.startupPlan.launchFile } : {}),
...(args.startupPlan.launchPrompt ? { launchPrompt: args.startupPlan.launchPrompt } : {}),
...(args.shouldSeedInitialAgentStatus
? { initialAgentStatus: { agent: args.agent, prompt: args.prompt.trim() } }
: {}),
@@ -30,7 +30,7 @@ import { ensureHooksConfirmed, confirmRuntimeIssueCommandRead } from '@/lib/ensu
import { useAppStore } from '@/store'
import type { SetupDecision } from '../../../../shared/worktree/create-types'
import { resolveComposerBranchNameOverrideForCreate } from '../composer-branch-selection'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { planLaunchPrompt } from '@/lib/tui-agent-startup'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
@@ -39,7 +39,8 @@ 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 { launchHostWritesLaunchFile } from '@/lib/launch-file-host'
import { launchHostIsPaired } from '@/lib/launch-file-host'
import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan'
import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions'
export function useFullSubmitPreparation(input: FullSubmitPreparationInput) {
@@ -181,30 +182,33 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) {
!effectiveBranchNameOverride &&
!createDisplayName
const startupPlan = buildAgentStartupPlan({
agent: tuiAgent,
prompt: submitStartupPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv),
sessionOptions: resolveInitialNativeChatSessionOptions(
{
experimentalNativeChat: settings?.experimentalNativeChat,
openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault,
nativeChatSessionOptions: settings?.nativeChatSessionOptions
},
{
agent: tuiAgent,
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
selectedRepo?.connectionId
)
}
),
platform: selectedRepoAgentLaunchPlatform,
shell: selectedRepoStartupShell,
isRemote: selectedRepoIsRemote,
hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings)
})
const startupPlan = composerAgentStartupPlan(
planLaunchPrompt({
agent: tuiAgent,
prompt: submitStartupPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv),
sessionOptions: resolveInitialNativeChatSessionOptions(
{
experimentalNativeChat: settings?.experimentalNativeChat,
openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault,
nativeChatSessionOptions: settings?.nativeChatSessionOptions
},
{
agent: tuiAgent,
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
selectedRepo?.connectionId
)
}
),
platform: selectedRepoAgentLaunchPlatform,
shell: selectedRepoStartupShell,
isRemote: selectedRepoIsRemote,
launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId)
}),
submitStartupPrompt
)
const shouldSeedInitialAgentStatus =
tuiAgent === 'command-code' && submitStartupPrompt.trim().length > 0
@@ -217,7 +221,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) {
}
const backendStartup =
startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt
startupPlan && !startupPlan.draftPrompt && !startupPlan.pastePromptAfterReady
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
@@ -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 { launchHostWritesLaunchFile } from '@/lib/launch-file-host'
import { launchHostIsPaired } 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,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
platform: selectedRepoAgentLaunchPlatform,
shell: selectedRepoStartupShell,
isRemote: selectedRepoIsRemote,
hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings),
launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId),
telemetrySource
})
@@ -3,8 +3,11 @@ import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { buildAgentDraftLaunchPlan, planLaunchPrompt } from '@/lib/tui-agent-startup'
import {
composerAgentStartupPlan,
type ComposerAgentStartupPlan
} from '@/lib/composer-agent-startup-plan'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
@@ -22,12 +25,13 @@ export type QuickComposerStartupInput = {
platform: NodeJS.Platform
shell: AgentStartupShell | null | undefined
isRemote: boolean
hostWritesLaunchFile: boolean
/** See `launchHostIsPaired`. */
launchHostIsPaired: boolean
telemetrySource: WorktreeCreationRequest['telemetrySource']
}
export type QuickComposerStartup = {
startupPlan: AgentStartupPlan | null
startupPlan: ComposerAgentStartupPlan | null
backendStartup: WorktreeCreationRequest['startup']
telemetry: AgentStartedTelemetry | null
}
@@ -67,13 +71,12 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui
shell: input.shell ?? undefined,
isRemote: input.isRemote
})
let startupPlan: AgentStartupPlan | null = null
let startupPlan: ComposerAgentStartupPlan | null = null
if (draftLaunchPlan) {
startupPlan = {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}),
...(draftLaunchPlan.startupCommandDelivery
@@ -82,19 +85,21 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui
...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {})
}
} else if (agent !== null) {
startupPlan = buildAgentStartupPlan({
agent,
prompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
sessionOptions,
platform: input.platform,
shell: input.shell ?? undefined,
isRemote: input.isRemote,
hostWritesLaunchFile: input.hostWritesLaunchFile,
allowEmptyPromptLaunch: true
})
startupPlan = composerAgentStartupPlan(
planLaunchPrompt({
agent,
prompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
sessionOptions,
platform: input.platform,
shell: input.shell ?? undefined,
isRemote: input.isRemote,
launchHostIsPaired: input.launchHostIsPaired
}),
prompt
)
if (startupPlan && draftPrompt) {
startupPlan.draftPrompt = draftPrompt
}
@@ -109,7 +114,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui
request_kind: 'new'
}
const backendStartup =
startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt
startupPlan && !startupPlan.draftPrompt && !startupPlan.pastePromptAfterReady
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
@@ -39,6 +39,9 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({
seedNativeChatAppliedSessionOptions: vi.fn()
}))
vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) }))
vi.mock('@/lib/agent-launch-prompt-receipt', () => ({
waitForLaunchPromptReceipt: vi.fn(async () => true)
}))
vi.mock('@/lib/agent-ready-wait', () => ({
waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' }))
}))
@@ -39,6 +39,9 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({
seedNativeChatAppliedSessionOptions: vi.fn()
}))
vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) }))
vi.mock('@/lib/agent-launch-prompt-receipt', () => ({
waitForLaunchPromptReceipt: vi.fn(async () => true)
}))
vi.mock('@/lib/agent-ready-wait', () => ({
waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' }))
}))
@@ -48,6 +48,10 @@ 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)
vi.mock('@/lib/agent-launch-prompt-receipt', () => ({
waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt
}))
vi.mock('@/runtime/local-runtime-capabilities', () => ({
readLocalRuntimeCapabilitiesOrUnknown: () => []
}))
@@ -159,14 +163,16 @@ describe('agent launch caller prompt transport', () => {
)
it.each(cases)(
'exposes no delivery promise to %s, whose Codex prompt rides argv or is a draft',
'exposes a delivery result to %s only for a prompt it waits to see submitted',
async (_id, profile) => {
const result = await launch(profile)
// Why: three call sites branch on this promise being present. A draft launch must NOT get one,
// because the composer owns the text until the user sends it, and a prompt the launch command
// carries has no paste to wait for.
expect(result?.promptDeliveryResult).toBeUndefined()
// Why: call sites branch on this result being present. A draft launch must NOT get one,
// because the composer owns the text until the user sends it; a submit-after-ready prompt the
// launch command carries still waits on the agent's receipt before the caller acts.
const waitsForSubmit =
profile.args.prompt !== undefined && profile.args.promptDelivery === 'submit-after-ready'
expect(result?.promptDeliveryResult !== undefined).toBe(waitsForSubmit)
}
)
@@ -215,6 +221,38 @@ describe('agent launch caller prompt transport', () => {
}
})
// The bug class the explicit carry outcome removes: a plan that exists but does not carry the
// prompt was treated as delivered, and the prompt was dropped.
it.each([
['past the argv ceiling', 'x'.repeat(20_000), 'darwin' as const],
['a Windows-damaged prompt', 'say "hi"', 'win32' as const]
])(
'pastes %s for an agent not measured reading a launch file, and waits for that paste',
async (_label, prompt, launchPlatform) => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
const result = launchAgentInNewTab({
agent: 'gemini',
worktreeId: 'wt-1',
prompt,
promptDelivery: 'submit-after-ready',
launchPlatform
})
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({
content: prompt,
submit: true
})
expect(mockWaitForLaunchPromptReceipt).not.toHaveBeenCalled()
expect(queuedStartupCommand(store)).not.toContain(prompt.slice(0, 8))
expect(queuedStartupPayload(store)?.launchFile).toBeUndefined()
}
)
it('mirrors an argv-carried draft into the chat composer', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type ReceiptState = {
tabsByWorktree: Record<string, { id: string }[]>
ptyIdsByTabId: Record<string, string[]>
agentStatusByPaneKey: Record<string, { updatedAt: number }>
settings: Record<string, unknown>
}
const mocks = vi.hoisted(() => {
let state: ReceiptState = {
tabsByWorktree: {},
ptyIdsByTabId: {},
agentStatusByPaneKey: {},
settings: {}
}
const listeners = new Set<(next: ReceiptState) => void>()
return {
getState: () => state,
setState: (next: Partial<ReceiptState>) => {
state = { ...state, ...next }
for (const listener of listeners) {
listener(state)
}
},
subscribe: (listener: (next: ReceiptState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
reset: () => {
listeners.clear()
state = { tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} }
},
readiness: vi.fn(),
waitForAgentReady: vi.fn()
}
})
vi.mock('@/store', () => ({
useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe }
}))
vi.mock('./agent-paste-draft', () => ({
PTY_SPAWN_TIMEOUT_MS: 8000,
getSettingsForAgentTabRuntimeOwner: () => ({}),
waitForAgentDraftInputReadyOnTab: mocks.readiness
}))
vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: mocks.waitForAgentReady }))
import { waitForLaunchPromptReceipt } from './agent-launch-prompt-receipt'
const never = (): Promise<never> => new Promise(() => {})
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()
})
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.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
mocks.setState({
agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 150 } }
})
await expect(receipt).resolves.toBe(true)
})
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)
})
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 })
mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } })
mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } })
await expect(receipt).resolves.toBe(false)
})
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()
})
it('is not delivered when the tab closes first', async () => {
const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 })
mocks.setState({ tabsByWorktree: { 'wt-1': [] } })
await expect(receipt).resolves.toBe(false)
})
})
@@ -0,0 +1,98 @@
import type { TuiAgent } from '../../../shared/tui-agent'
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'
type AppState = ReturnType<typeof useAppStore.getState>
function hookReportedTurnOnTab(state: AppState, tabId: string, launchedAt: number): boolean {
return Object.entries(state.agentStatusByPaneKey).some(
([paneKey, entry]) => parsePaneKey(paneKey)?.tabId === tabId && entry.updatedAt >= launchedAt
)
}
function tabExists(state: AppState, tabId: string): boolean {
return Object.values(state.tabsByWorktree).some((tabs) => tabs?.some((tab) => tab.id === tabId))
}
/**
* 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".
*/
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> {
const { tabId, agent, launchedAt } = args
return new Promise((resolve) => {
let settled = false
let sawPty = false
let unsubscribe: (() => void) | null = null
const finish = (delivered: boolean): void => {
if (settled) {
return
}
settled = true
unsubscribe?.()
resolve(delivered)
}
let scannedStatus: AppState['agentStatusByPaneKey'] | null = null
const observe = (state: AppState): void => {
// Why the identity check: the store writes often and replaces this map only when it changes.
if (state.agentStatusByPaneKey !== scannedStatus) {
scannedStatus = state.agentStatusByPaneKey
if (hookReportedTurnOnTab(state, tabId, launchedAt)) {
finish(true)
return
}
}
if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) {
sawPty = true
} else if (sawPty || !tabExists(state, tabId)) {
finish(false)
}
}
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
}
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)
)
})
}
+2 -2
View File
@@ -42,7 +42,7 @@ export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_M
// hasn't appeared in 8s means the launch itself failed — waiting the (longer)
// composer budget on top would only delay that verdict. Keeping them distinct
// also stops one slow step from spending the other's budget (STA-3367).
const PTY_SPAWN_TIMEOUT_MS = 8000
export const PTY_SPAWN_TIMEOUT_MS = 8000
export function getSettingsForAgentTabRuntimeOwner(
tabId: string
@@ -273,7 +273,7 @@ async function sendBracketedPasteToAgent(args: {
}
}
function waitForAgentDraftInputReadyOnTab(args: {
export function waitForAgentDraftInputReadyOnTab(args: {
tabId: string
spawnTimeoutMs: number
readinessTimeoutMs: number
@@ -1,5 +1,5 @@
import { useAppStore } from '@/store'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import {
agentStartupDeliveryKey as deliveryKey,
@@ -15,8 +15,8 @@ type PendingAgentStartupDelivery = {
worktreeId: string
tabId: string
launchToken: string
startup: AgentStartupPlan
deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise<void>
startup: ComposerAgentStartupPlan
deliver: (tabId: string, ptyId: string, startup: ComposerAgentStartupPlan) => Promise<void>
}
const pendingAgentStartupDeliveries = new Map<string, PendingAgentStartupDelivery>()
@@ -0,0 +1,31 @@
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { LaunchPromptPlan } from '../../../shared/launch-prompt-carry'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
/** What a created workspace launches, and the submitted prompt it still owes the agent, if any. */
export type ComposerAgentStartupPlan = AgentStartupPlan & {
launchFile?: LaunchFile
/** The submitted prompt the launch carries; handed back to copy if the host refuses the spawn. */
launchPrompt?: string
/** A submitted prompt the launch could not carry; pasted once the agent is ready. */
pastePromptAfterReady?: string
}
export function composerAgentStartupPlan(
planned: LaunchPromptPlan<AgentStartupPlan> | null,
prompt: string
): ComposerAgentStartupPlan | null {
if (!planned) {
return null
}
switch (planned.carry) {
case 'none':
return planned.plan
case 'on-line':
return { ...planned.plan, launchPrompt: prompt.trim() }
case 'launch-file':
return { ...planned.plan, launchFile: planned.launchFile, launchPrompt: prompt.trim() }
case 'paste-after-ready':
return { ...planned.cleanPlan, pastePromptAfterReady: planned.text }
}
}
@@ -1,6 +1,7 @@
import { useAppStore } from '@/store'
import { launchHostWritesLaunchFile } from '@/lib/launch-file-host'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { launchHostIsPaired } from '@/lib/launch-file-host'
import { planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
import type {
LaunchAgentBackgroundSessionArgs,
LaunchAgentBackgroundSessionResult
@@ -75,23 +76,38 @@ export async function launchAgentBackgroundSession(
// Route by the worktree's owner host, not the focused runtime.
const ownerSettings = getSettingsForWorktreeRuntimeOwner(store, worktreeId)
const runtimeTarget = getActiveRuntimeTarget(ownerSettings)
const startupPlan = buildAgentStartupPlan({
const planned = planLaunchPrompt({
agent,
prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '',
prompt: trimmedPrompt,
cmdOverrides,
agentArgs,
agentEnv,
platform: launchPlatform,
shell: startupShell,
isRemote,
hostWritesLaunchFile: launchHostWritesLaunchFile(ownerSettings),
allowEmptyPromptLaunch: !hasPrompt || isFollowupPath
launchHostIsPaired: launchHostIsPaired(ownerSettings?.activeRuntimeEnvironmentId)
})
if (!startupPlan) {
if (!planned) {
return null
}
// A prompt the host could not be sent in a launch file waits for the paste, as a stdin agent's does.
const promptLeftForPaste = hasPrompt && !isFollowupPath ? startupPlan.followupPrompt : null
let startupPlan: AgentStartupPlan
let launchFile: LaunchFile | undefined
// An argv agent's prompt the line could not carry waits for the paste, as a stdin agent's does.
let promptLeftForPaste: string | null = null
switch (planned.carry) {
case 'none':
case 'on-line':
startupPlan = planned.plan
break
case 'launch-file':
startupPlan = planned.plan
launchFile = planned.launchFile
break
case 'paste-after-ready':
startupPlan = planned.cleanPlan
promptLeftForPaste = isFollowupPath ? null : planned.text
break
}
let pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste
// A hidden run tab must never be store-visible without its PTY (#2989).
@@ -192,7 +208,7 @@ export async function launchAgentBackgroundSession(
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
env: paneEnv,
...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}),
...(launchFile ? { launchFile } : {}),
launchConfig: startupPlan.launchConfig,
launchToken,
launchAgent: agent,
@@ -14,6 +14,7 @@ const mockMarkNativeChatLaunchPromptFailed = vi.fn()
const mockTrack = vi.fn()
const mockToastMessage = vi.fn()
const mockWaitForAgentReady = vi.fn()
const mockWaitForLaunchPromptReceipt = vi.fn()
const store = {
activeRepoId: 'repo-1',
@@ -117,6 +118,10 @@ vi.mock('@/lib/agent-ready-wait', () => ({
waitForAgentReady: mockWaitForAgentReady
}))
vi.mock('@/lib/agent-launch-prompt-receipt', () => ({
waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt
}))
vi.mock('@/lib/telemetry', () => ({
track: mockTrack,
tuiAgentToAgentKind: (agent: string) => agent
@@ -137,6 +142,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({
describe('launchAgentInNewTab', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWaitForLaunchPromptReceipt.mockResolvedValue(true)
mockIsWebRuntimeSessionActive.mockReturnValue(false)
mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' })
mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' })
@@ -699,7 +705,14 @@ describe('launchAgentInNewTab', () => {
promptDelivery: 'submit-after-ready'
})
expect(result?.promptDeliveryResult).toBeUndefined()
// Why: the launch line carried it, so delivery is the agent's receipt, never the tab existing.
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
expect(mockWaitForLaunchPromptReceipt).toHaveBeenCalledWith(
expect.objectContaining({ tabId: 'tab-1', agent: 'command-code' })
)
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
// Why: Command Code has no prompt-submit hook, so the spawn seeds working from this prompt.
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
@@ -839,14 +852,36 @@ describe('launchAgentInNewTab', () => {
promptDelivery: 'submit-after-ready'
})
expect(result?.promptDeliveryResult).toBeUndefined()
await expect(result?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1]
// Handed back to copy if the host refuses to write the file.
expect(queued?.launchPrompt).toBe(prompt)
expect(queued?.launchFile).toMatchObject({ content: prompt, sensitive: false })
expect(queued?.command).toContain(queued?.launchFile?.placeholder)
expect(queued?.command).not.toContain('xxxx')
})
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)
const onPromptDelivered = vi.fn()
const result = launchAgentInNewTab({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'resolve these threads',
promptDelivery: 'submit-after-ready',
onPromptDelivered
})
await expect(result?.promptDeliveryResult).resolves.toMatchObject({ delivered: false })
expect(onPromptDelivered).not.toHaveBeenCalled()
})
it('queues per-launch CLI arguments ahead of the generated prompt on argv', async () => {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
+23 -48
View File
@@ -3,12 +3,8 @@ 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 { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice'
import {
deliverLaunchPromptToAgentTab,
seedNativeChatLaunchDraftForAgentTab,
seedNativeChatLaunchPromptForAgentTab
} from '@/lib/agent-launch-prompt-delivery'
import { launchHostIsPaired } 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'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
@@ -157,14 +153,14 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
sessionOptions: resolveInitialNativeChatSessionOptions(store.settings, initialViewModeOptions)
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId)
const launchesOnPairedHost = isWebRuntimeSessionActive(runtimeEnvironmentId)
const hostPublishesTab = isWebRuntimeSessionActive(runtimeEnvironmentId)
const { startupPlan, launchFile, pasteDraftAfterLaunch, submitPastedPrompt } =
planLaunchAgentStartupPrompt({
base: startupPlanBase,
prompt: trimmedPrompt,
promptDelivery,
isFollowupPath,
launchesOnPairedHost
launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId)
})
let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined
@@ -172,7 +168,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
return null
}
if (launchesOnPairedHost) {
if (hostPublishesTab) {
if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) {
return null
}
@@ -246,6 +242,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
if (beforeSurfaceOpen?.({ kind: 'local-terminal' }) === false) {
return null
}
const launchedAt = Date.now()
// Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render.
// Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit.
const tab = store.createTab(worktreeId, groupId, undefined, {
@@ -270,6 +267,9 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
...(launchFile ? { launchFile } : {}),
...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery !== 'draft'
? { launchPrompt: trimmedPrompt }
: {}),
// Why: Command Code has no prompt-submit hook, so a prompt its launch line submits seeds working.
...(agent === 'command-code' &&
hasPrompt &&
@@ -283,45 +283,20 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
request_kind: 'new'
}
})
// Why: fire-and-forget the paste-after-ready delivery so callers keep the synchronous { tabId, startupPlan } signature.
// Why: safe to call unconditionally — the helper short-circuits (no paste) for native-prefill agents already holding the draft.
// Why: no paste runs, so no paste seeds the chat's copy: a draft rode in on argv (Claude --prefill
// etc.), and a submitted prompt rode the launch line. Not with a launch file: the transcript then
// shows the pointer sentence, which would never prune this copy.
if (hasPrompt && pasteDraftAfterLaunch === null && !launchFile) {
if (promptDelivery === 'draft') {
seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt })
} else if (promptDelivery === 'submit-after-ready') {
seedNativeChatLaunchPromptForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt })
}
}
if (pasteDraftAfterLaunch !== null) {
const timeoutNotice = createPasteReadinessTimeoutNotice({
worktreeId,
tabId: tab.id,
agent,
submitted: submitPastedPrompt,
content: pasteDraftAfterLaunch
})
const deliveryPromise = deliverLaunchPromptToAgentTab({
tabId: tab.id,
content: pasteDraftAfterLaunch,
agent,
submit: submitPastedPrompt,
forcePaste: true,
onTimeout: timeoutNotice.onTimeout,
...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {})
}).then((delivered) => timeoutNotice.settle(delivered, onPromptDelivered))
if (promptDelivery === 'submit-after-ready') {
promptDeliveryResult = deliveryPromise
} else {
void deliveryPromise.catch((error) =>
console.error('Prompt delivery failed after launch', error)
)
}
} else if (hasPrompt) {
onPromptDelivered?.()
}
// Why: fire-and-forget the delivery so callers keep the synchronous { tabId, startupPlan } signature.
promptDeliveryResult = deliverNewTabLaunchPrompt({
worktreeId,
tabId: tab.id,
agent,
prompt: trimmedPrompt,
promptDelivery,
pasteDraftAfterLaunch,
submitPastedPrompt,
promptInLaunchFile: launchFile !== undefined,
launchedAt,
...(onPromptDelivered ? { onPromptDelivered } : {}),
...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {})
})
// Why: without setActiveTabType('terminal') an activated launch can stay hidden behind an editor.
// Scoped to the launch's worktree so a floating or background launch leaves the main window's tab alone.
@@ -0,0 +1,83 @@
import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice'
import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt'
import {
deliverLaunchPromptToAgentTab,
seedNativeChatLaunchDraftForAgentTab,
seedNativeChatLaunchPromptForAgentTab
} from '@/lib/agent-launch-prompt-delivery'
import type { TuiAgent } from '../../../shared/tui-agent'
export type NewTabPromptDeliveryResult = Promise<{ delivered: boolean; failureNotified: boolean }>
/**
* How a new agent tab's prompt is delivered, and when it counts as delivered: a paste once the agent
* is ready, or for a prompt the launch carried, the agent's receipt of it. Never the tab existing.
* Returns the result a `submit-after-ready` caller waits on; other callers fire and forget.
*/
export function deliverNewTabLaunchPrompt(args: {
worktreeId: string
tabId: string
agent: TuiAgent
/** Trimmed; empty for a launch with no prompt. */
prompt: string
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
pasteDraftAfterLaunch: string | null
submitPastedPrompt: boolean
promptInLaunchFile: boolean
launchedAt: number
onPromptDelivered?: () => void
onPromptDeliveryUnconfirmed?: () => void
}): NewTabPromptDeliveryResult | undefined {
const { tabId, agent, prompt, promptDelivery, pasteDraftAfterLaunch } = args
if (!prompt) {
return undefined
}
const unconfirmed = args.onPromptDeliveryUnconfirmed
? { onUnconfirmedDelivery: args.onPromptDeliveryUnconfirmed }
: {}
// Why: no paste runs, so no paste seeds the chat's copy: a draft rode in on argv (Claude --prefill
// etc.), and a submitted prompt rode the launch line. Not with a launch file: the transcript then
// shows the pointer sentence, which would never prune this copy.
if (pasteDraftAfterLaunch === null && !args.promptInLaunchFile) {
if (promptDelivery === 'draft') {
seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: prompt })
} else if (promptDelivery === 'submit-after-ready') {
seedNativeChatLaunchPromptForAgentTab({ tabId, agent, text: prompt })
}
}
if (pasteDraftAfterLaunch === null && promptDelivery === 'draft') {
// A draft rode the launch command as an editable prefill; there is no turn to wait for.
args.onPromptDelivered?.()
return undefined
}
const content = pasteDraftAfterLaunch ?? prompt
const submitted = pasteDraftAfterLaunch === null || args.submitPastedPrompt
const timeoutNotice = createPasteReadinessTimeoutNotice({
worktreeId: args.worktreeId,
tabId,
agent,
submitted,
content
})
// Why the receipt for a carried prompt: callers post replies and resolve threads on this result.
const delivery =
pasteDraftAfterLaunch === null
? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt, ...unconfirmed })
: deliverLaunchPromptToAgentTab({
tabId,
content,
agent,
submit: submitted,
forcePaste: true,
onTimeout: timeoutNotice.onTimeout,
...unconfirmed
})
const result = delivery.then((delivered) =>
timeoutNotice.settle(delivered, args.onPromptDelivered)
)
if (promptDelivery === 'submit-after-ready') {
return result
}
void result.catch((error) => console.error('Prompt delivery failed after launch', error))
return undefined
}
@@ -1,16 +1,12 @@
import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
type AgentStartupPlan
planLaunchPrompt,
type AgentStartupPlan,
type AgentStartupPlanInputs
} from '@/lib/tui-agent-startup'
import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
type StartupPlanBase = Omit<
Parameters<typeof buildAgentStartupPlan>[0],
'prompt' | 'allowEmptyPromptLaunch'
>
export type LaunchAgentStartupPromptPlan = {
startupPlan: AgentStartupPlan | null
/** Written by the host before it types the launch line naming it. */
@@ -21,37 +17,41 @@ export type LaunchAgentStartupPromptPlan = {
}
/**
* Decide how a new-tab launch delivers its prompt: agents whose CLI takes one get it on the launch
* command, while agents that take text only after start launch clean and paste once ready.
* Decide how a new-tab launch delivers its prompt: on the agent's launch command or in a launch file
* where `carryLaunchPrompt` says it can ride, else pasted once the agent is ready.
*/
export function planLaunchAgentStartupPrompt(args: {
base: StartupPlanBase
base: AgentStartupPlanInputs
/** Already trimmed. */
prompt: string
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
isFollowupPath: boolean
/** A paired host of unknown version may neither stage a long line nor write a launch file. */
launchesOnPairedHost: boolean
/** See `launchHostIsPaired`. */
launchHostIsPaired: boolean
}): LaunchAgentStartupPromptPlan {
const { base, prompt, promptDelivery, isFollowupPath } = args
const hasPrompt = prompt.length > 0
const pasteAfterReady = (submit: boolean): LaunchAgentStartupPromptPlan => ({
startupPlan: buildAgentStartupPlan({ ...base, prompt: '', allowEmptyPromptLaunch: true }),
pasteDraftAfterLaunch: prompt,
const pasteAfterReady = (
startupPlan: AgentStartupPlan | null,
text: string,
submit: boolean
): LaunchAgentStartupPromptPlan => ({
startupPlan,
pasteDraftAfterLaunch: text,
submitPastedPrompt: submit
})
const cleanPlan = (): AgentStartupPlan | null =>
buildAgentStartupPlan({ ...base, allowEmptyPromptLaunch: true })
if (hasPrompt && promptDelivery === 'draft') {
if (prompt.length > 0 && promptDelivery === 'draft') {
const draftLaunchPlan = buildAgentDraftLaunchPlan({ ...base, draft: prompt })
if (!draftLaunchPlan) {
return pasteAfterReady(false)
return pasteAfterReady(cleanPlan(), prompt, false)
}
return {
startupPlan: {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.sessionOptions
? { sessionOptions: draftLaunchPlan.sessionOptions }
@@ -65,29 +65,35 @@ export function planLaunchAgentStartupPrompt(args: {
submitPastedPrompt: false
}
}
// Temporary, until paired hosts advertise staging: keep their paste after readiness.
if (
hasPrompt &&
(isFollowupPath || (args.launchesOnPairedHost && promptDelivery === 'submit-after-ready'))
) {
return pasteAfterReady(promptDelivery === 'submit-after-ready')
if (prompt.length > 0 && isFollowupPath) {
return pasteAfterReady(cleanPlan(), prompt, promptDelivery === 'submit-after-ready')
}
if (!hasPrompt || args.launchesOnPairedHost) {
const startupPlan = buildAgentStartupPlan({
...base,
prompt,
allowEmptyPromptLaunch: !hasPrompt
})
// A paired host is sent a command, never a launch file, so a prompt that needs one is pasted.
return startupPlan?.launchFile
? pasteAfterReady(true)
: { startupPlan, pasteDraftAfterLaunch: null, submitPastedPrompt: false }
// 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') {
return pasteAfterReady(cleanPlan(), prompt, true)
}
const carried = planStartupWithLaunchPrompt(base, prompt)
return {
startupPlan: carried.plan,
...(carried.launchFile ? { launchFile: carried.launchFile } : {}),
pasteDraftAfterLaunch: null,
submitPastedPrompt: false
const planned = planLaunchPrompt({
...base,
prompt,
launchHostIsPaired: args.launchHostIsPaired
})
if (!planned) {
return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false }
}
switch (planned.carry) {
case 'none':
case 'on-line':
return { startupPlan: planned.plan, pasteDraftAfterLaunch: null, submitPastedPrompt: false }
case 'launch-file':
return {
startupPlan: planned.plan,
launchFile: planned.launchFile,
pasteDraftAfterLaunch: null,
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)
}
}
@@ -1,17 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { launchHostWritesLaunchFile } from './launch-file-host'
import { launchHostIsPaired } from './launch-file-host'
import { buildQuickComposerStartup } from '@/hooks/composer-state/quick-startup-plan'
describe('whether a launch host writes its launch file', () => {
describe('whether a launch lands on a paired host', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('is true for this machine and false for a paired host or a web client', () => {
expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(true)
expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: 'env-1' })).toBe(false)
it('is false for this machine and true for another Orca or a web client', () => {
expect(launchHostIsPaired(null)).toBe(false)
expect(launchHostIsPaired('env-1')).toBe(true)
vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true })
expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(false)
expect(launchHostIsPaired(null)).toBe(true)
})
})
@@ -26,11 +26,11 @@ describe('the quick composer on a paired host', () => {
platform: 'win32',
shell: 'powershell',
isRemote: false,
hostWritesLaunchFile: false,
launchHostIsPaired: true,
telemetrySource: 'sidebar'
})
expect(startup.backendStartup).toBeUndefined()
expect(startup.startupPlan?.launchFile).toBeUndefined()
expect(startup.startupPlan?.followupPrompt).toBe('fix the build\nthen run the tests')
expect(startup.startupPlan?.pastePromptAfterReady).toBe('fix the build\nthen run the tests')
})
})
+7 -9
View File
@@ -1,14 +1,12 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target'
import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment'
import { isWebClientLocation } from './web-client-location'
/**
* Whether the host a launch lands on writes the launch file its line names. A paired host (another
* Orca this client drives) is sent a command and never the file, so a prompt that would need one is
* pasted after the agent is ready instead. Temporary, until paired hosts are sent the prompt itself.
* 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.
*/
export function launchHostWritesLaunchFile(
ownerSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): boolean {
return !isWebClientLocation() && getActiveRuntimeTarget(ownerSettings).kind === 'local'
export function launchHostIsPaired(runtimeEnvironmentId: string | null | undefined): boolean {
return isWebClientLocation() || isWebRuntimeSessionActive(runtimeEnvironmentId)
}
@@ -18,7 +18,7 @@ export function buildDirectWorkItemStartup(args: {
launchConnectionId: string | null
worktreePath: string
repoProjectRuntime?: Parameters<typeof resolveSourceControlLaunchPlatform>[0]['projectRuntime']
launchesOnPairedHost: boolean
launchHostIsPaired: boolean
}): ReturnType<typeof buildDirectWorkItemAgentStartupPlan> {
const launchPlatform =
args.launchPlatform ??
@@ -39,7 +39,7 @@ 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',
launchesOnPairedHost: args.launchesOnPairedHost
launchHostIsPaired: args.launchHostIsPaired
})
}
@@ -26,7 +26,6 @@ describe('buildDirectWorkItemStartupOpts', () => {
agent: 'codex',
launchCommand: "codex 'review linked issue'",
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
startupCommandDelivery: 'shell-ready'
}
@@ -53,7 +52,6 @@ describe('buildDirectWorkItemStartupOpts', () => {
agent: 'claude',
launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'",
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
}
@@ -85,6 +83,7 @@ const settings = {
describe('buildDirectWorkItemAgentStartupPlan', () => {
it('omits native-chat preferences when the new workspace opens in terminal mode', () => {
const result = buildDirectWorkItemAgentStartupPlan({
launchHostIsPaired: false,
agent: 'codex',
draftContent: 'Review issue 42',
promptDelivery: 'draft',
@@ -99,6 +98,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => {
it('applies native-chat preferences when the new workspace opens in chat', () => {
const result = buildDirectWorkItemAgentStartupPlan({
launchHostIsPaired: false,
agent: 'codex',
draftContent: 'Review issue 42',
promptDelivery: 'draft',
@@ -147,6 +147,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
it('resolves the global Agents arguments when the launch names none', () => {
const result = buildDirectWorkItemAgentStartupPlan({
launchHostIsPaired: false,
agent: 'codex',
draftContent: 'Fix the broken checks',
promptDelivery: 'draft',
@@ -160,6 +161,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
it('lets an explicit per-action value win over the global one', () => {
const result = buildDirectWorkItemAgentStartupPlan({
launchHostIsPaired: false,
agent: 'codex',
agentArgs: '--model gpt-5',
draftContent: 'Fix the broken checks',
@@ -173,3 +175,38 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () =>
expect(result.startupPlan?.launchCommand).not.toContain('danger-full-access')
})
})
describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => {
const submit = (agent: 'claude' | 'gemini', draftContent: string, launchHostIsPaired = false) =>
buildDirectWorkItemAgentStartupPlan({
launchHostIsPaired,
agent,
draftContent,
promptDelivery: 'submit-after-ready',
settings: { ...settings, openAgentTabsInChatByDefault: false },
launchPlatform: 'darwin',
nativeChatTranscriptIsLocalReadable: true
})
// The bug class the explicit carry outcome removes: a plan that exists but does not carry the
// prompt was reported as on the launch command, so no paste ran and the prompt was dropped.
it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => {
const result = submit('gemini', 'g'.repeat(20_000))
expect(result.promptOnLaunchCommand).toBe(false)
expect(result.startupPlan?.launchCommand).not.toContain('gggg')
expect(result.launchFile).toBeUndefined()
})
it('hands Claude the same prompt in a launch file, with the prompt to copy if refused', () => {
const prompt = 'c'.repeat(20_000)
const result = submit('claude', prompt)
expect(result.promptOnLaunchCommand).toBe(true)
expect(result.launchFile?.content).toBe(prompt)
expect(result.launchPrompt).toBe(prompt)
})
it('pastes on a paired host what its line cannot carry typed', () => {
expect(submit('claude', 'fix it', true).promptOnLaunchCommand).toBe(true)
expect(submit('claude', 'fix it\nthen run the tests', true).promptOnLaunchCommand).toBe(false)
})
})
@@ -3,6 +3,7 @@ import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
planLaunchPrompt,
type AgentStartupPlan
} from '@/lib/tui-agent-startup'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
@@ -18,8 +19,6 @@ 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 { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry'
import { agentPromptRidesLaunchCommand } from '../../../shared/tui-agent-startup'
export function buildDirectWorkItemAgentStartupPlan(args: {
agent: TuiAgent | null
@@ -42,11 +41,13 @@ 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
/** A paired host of unknown version may neither stage a long line nor write a launch file. */
launchesOnPairedHost?: boolean
/** See `launchHostIsPaired`. */
launchHostIsPaired: boolean
}): {
startupPlan: AgentStartupPlan | null
launchFile?: LaunchFile
/** The submitted prompt the launch command carries (on its line or in `launchFile`). */
launchPrompt?: string
/** The launch command carries the content (a native draft or a submitted prompt); no paste runs. */
promptOnLaunchCommand: boolean
startupPlanFailed: boolean
@@ -76,33 +77,51 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
agentEnv: effectiveAgentEnv,
sessionOptions
}
// Temporary, until paired hosts advertise staging: they keep the paste after readiness.
if (
args.promptDelivery === 'submit-after-ready' &&
!args.launchesOnPairedHost &&
agentPromptRidesLaunchCommand(args.agent)
) {
const carried = planStartupWithLaunchPrompt(planInputs, args.draftContent)
return {
startupPlan: carried.plan,
...(carried.launchFile ? { launchFile: carried.launchFile } : {}),
promptOnLaunchCommand: carried.plan !== null,
startupPlanFailed: carried.plan === null
if (args.promptDelivery === 'submit-after-ready') {
const planned = planLaunchPrompt({
...planInputs,
prompt: args.draftContent,
launchHostIsPaired: args.launchHostIsPaired
})
if (!planned) {
return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true }
}
switch (planned.carry) {
case 'none':
return { startupPlan: planned.plan, promptOnLaunchCommand: true, startupPlanFailed: false }
case 'on-line':
return {
startupPlan: planned.plan,
launchPrompt: args.draftContent,
promptOnLaunchCommand: true,
startupPlanFailed: false
}
case 'launch-file':
return {
startupPlan: planned.plan,
launchFile: planned.launchFile,
launchPrompt: args.draftContent,
promptOnLaunchCommand: true,
startupPlanFailed: false
}
case 'paste-after-ready':
return {
startupPlan: planned.cleanPlan,
promptOnLaunchCommand: false,
startupPlanFailed: false
}
}
}
const draftLaunchPlan =
args.promptDelivery === 'submit-after-ready'
? null
: buildAgentDraftLaunchPlan({
agent: args.agent,
draft: args.draftContent,
cmdOverrides: args.settings?.agentCmdOverrides ?? {},
platform: args.launchPlatform,
isRemote: args.isRemote,
agentArgs: effectiveAgentArgs,
agentEnv: effectiveAgentEnv,
sessionOptions
})
const draftLaunchPlan = buildAgentDraftLaunchPlan({
agent: args.agent,
draft: args.draftContent,
cmdOverrides: args.settings?.agentCmdOverrides ?? {},
platform: args.launchPlatform,
isRemote: args.isRemote,
agentArgs: effectiveAgentArgs,
agentEnv: effectiveAgentEnv,
sessionOptions
})
if (draftLaunchPlan) {
return {
@@ -110,7 +129,6 @@ export function buildDirectWorkItemAgentStartupPlan(args: {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.sessionOptions
? { sessionOptions: draftLaunchPlan.sessionOptions }
@@ -153,7 +171,9 @@ export function buildDirectWorkItemStartupOpts(
/** Unsent launch context, for the view-mode decision only. Set it for every
* draft launch — a natively-prefilled plan carries no `draftPrompt`. */
launchDraftText?: string,
launchFile?: LaunchFile
/** What the launch command carries: its launch file, and the submitted prompt (see
* `PtyPaneStartup.launchPrompt`). */
carried: { launchFile?: LaunchFile; launchPrompt?: string } = {}
): {
startup?: {
command: string
@@ -165,6 +185,7 @@ export function buildDirectWorkItemStartupOpts(
sessionOptions?: AgentStartupPlan['sessionOptions']
startupCommandDelivery?: StartupCommandDelivery
launchFile?: LaunchFile
launchPrompt?: string
telemetry?: AgentStartedTelemetry
}
} {
@@ -187,7 +208,8 @@ export function buildDirectWorkItemStartupOpts(
...(plan.startupCommandDelivery
? { startupCommandDelivery: plan.startupCommandDelivery }
: {}),
...(launchFile ? { launchFile } : {}),
...(carried.launchFile ? { launchFile: carried.launchFile } : {}),
...(carried.launchPrompt ? { launchPrompt: carried.launchPrompt } : {}),
...(telemetry ? { telemetry } : {})
}
}
@@ -4,7 +4,7 @@ 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 { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session'
import { launchHostIsPaired } from '@/lib/launch-file-host'
import type { LaunchFile } from '../../../shared/launch-prompt-file'
import type {
AgentSessionLaunchPlan,
@@ -21,6 +21,7 @@ export type DirectWorkItemAgentLaunchPreparation = {
effectiveAgent: TuiAgent | null
startupPlan: ReturnType<typeof buildDirectWorkItemStartup>['startupPlan']
launchFile?: LaunchFile
launchPrompt?: string
promptOnLaunchCommand: boolean
startupPlanFailed: boolean
structuredLaunch: boolean
@@ -74,7 +75,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
// Non-critical: activation still has the explicit startup below.
})
}
const { startupPlan, launchFile, promptOnLaunchCommand, startupPlanFailed } =
const { startupPlan, launchFile, launchPrompt, promptOnLaunchCommand, startupPlanFailed } =
buildDirectWorkItemStartup({
agent: effectiveAgent,
agentArgs: args.agentArgs,
@@ -92,7 +93,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
CLIENT_PLATFORM
) ?? args.repoProjectRuntime)
: undefined,
launchesOnPairedHost: isWebRuntimeSessionActive(
launchHostIsPaired: launchHostIsPaired(
getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId)
)
})
@@ -115,6 +116,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
effectiveAgent,
startupPlan,
...(launchFile ? { launchFile } : {}),
...(launchPrompt ? { launchPrompt } : {}),
promptOnLaunchCommand,
startupPlanFailed,
structuredLaunch,
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store'
import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection'
import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup'
import type * as PromptCarryModule from '../../../shared/startup-line-prompt-carry'
import type * as DirectAgentRoutingModule from '@/lib/launch-work-item-direct-agent-routing'
const mocks = vi.hoisted(() => ({
@@ -104,17 +103,8 @@ vi.mock('@/lib/tui-agent-startup', async () => {
return {
...actual,
buildAgentDraftLaunchPlan: vi.fn(actual.buildAgentDraftLaunchPlan),
buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan)
}
})
vi.mock('../../../shared/startup-line-prompt-carry', async () => {
const actual = await vi.importActual<typeof PromptCarryModule>(
'../../../shared/startup-line-prompt-carry'
)
return {
...actual,
planStartupWithLaunchPrompt: vi.fn(actual.planStartupWithLaunchPrompt)
buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan),
planLaunchPrompt: vi.fn(actual.planLaunchPrompt)
}
})
@@ -141,9 +131,12 @@ vi.mock('@/lib/launch-work-item-direct-agent-routing', async () => {
import { launchWorkItemDirect } from './launch-work-item-direct'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
planLaunchPrompt
} from '@/lib/tui-agent-startup'
import { pickTuiAgent } from '../../../shared/tui-agent-selection'
import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry'
const mockApi = {
worktrees: {
@@ -600,7 +593,6 @@ describe('launchWorkItemDirect', () => {
agent: 'cursor',
launchCommand: 'cursor-agent',
expectedProcess: 'cursor-agent',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
})
mocks.store.createWorktree.mockResolvedValue({
@@ -813,12 +805,12 @@ describe('launchWorkItemDirect', () => {
})
).resolves.toBe(true)
expect(planStartupWithLaunchPrompt).toHaveBeenCalledWith(
expect(planLaunchPrompt).toHaveBeenCalledWith(
expect.objectContaining({
agent: 'codex',
platform: 'linux'
}),
'Fix the failing checks.'
platform: 'linux',
prompt: 'Fix the failing checks.'
})
)
})
})
@@ -249,7 +249,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
startupPlan,
launchSource,
promptDelivery === 'draft' ? draftContent : undefined,
launchPreparation.launchFile
launchPreparation
))
})
return activationHolder.value !== false
+7 -16
View File
@@ -288,7 +288,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner',
pastePromptAfterReady: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -311,7 +311,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner',
pastePromptAfterReady: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -333,7 +333,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner',
pastePromptAfterReady: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -355,7 +355,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'claude',
launchCommand: 'claude',
expectedProcess: 'claude',
followupPrompt: 'fix the spinner',
pastePromptAfterReady: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -374,7 +374,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner',
pastePromptAfterReady: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -389,7 +389,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'claude',
launchCommand: 'claude',
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'review this before sending'
}
@@ -416,7 +415,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner',
pastePromptAfterReady: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -432,7 +431,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'claude',
launchCommand: 'claude',
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'review this before sending'
}
@@ -476,7 +474,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'Linear context draft'
}
@@ -506,7 +503,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'https://github.com/stablyai/orca/pull/2051'
}
@@ -563,7 +559,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'linked draft'
}
@@ -645,7 +640,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'linked draft'
}
@@ -684,7 +678,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'codex' as const,
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'linked draft',
launchToken: 'launch-token-1'
@@ -714,7 +707,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'codex' as const,
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'linked draft',
launchToken: 'launch-token-1'
@@ -775,7 +767,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} },
draftPrompt: 'old linked draft',
launchToken: 'launch-token-old'
@@ -827,7 +818,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'fix the spinner',
pastePromptAfterReady: 'fix the spinner',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
+8 -8
View File
@@ -7,6 +7,7 @@ import {
import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery'
import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan'
import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context'
import {
beginAgentStartupDeliveryAttempt,
@@ -228,11 +229,11 @@ export function getWorkspaceSeedName(args: {
export async function ensureAgentStartupInTerminal(args: {
worktreeId: string
primaryTabId?: string | null
startup: AgentStartupPlan
startup: ComposerAgentStartupPlan
}): Promise<void> {
const { worktreeId, primaryTabId, startup } = args
const draftPrompt = startup.draftPrompt ?? null
if (startup.followupPrompt === null && draftPrompt === null) {
if (!startup.pastePromptAfterReady && draftPrompt === null) {
return
}
const launchToken = ensureStartupLaunchToken(startup)
@@ -280,15 +281,14 @@ export async function ensureAgentStartupInTerminal(args: {
async function deliverAgentStartupToTerminal(
tabId: string,
ptyId: string,
startup: AgentStartupPlan
startup: ComposerAgentStartupPlan
): Promise<void> {
const draftPrompt = startup.draftPrompt ?? null
const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId)
// Why: followupPrompt is the legacy path for stdin-after-start agents
// (aider, goose, etc.) that need their initial prompt typed into the live
// session and submitted. Wait until the agent owns the PTY before writing.
if (startup.followupPrompt) {
const prompt = startup.followupPrompt
// Why: a stdin-after-start agent (aider, goose, etc.) needs its initial prompt typed into the
// live session and submitted. Wait until the agent owns the PTY before writing.
if (startup.pastePromptAfterReady) {
const prompt = startup.pastePromptAfterReady
// Why: an argv agent's prompt left for paste (a WSL line too long to type, a paired host) waits
// for the agent's composer and lands as one bracketed paste, not raw keys once the process shows.
const delivered = agentPromptRidesLaunchCommand(startup.agent)
@@ -10,7 +10,7 @@ import type {
WorkspaceLinkedItem,
WorkspaceStatus
} from '../../../shared/worktree/types'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context'
import type { AgentLaunchRoute } from '@/lib/agent-launch-routing'
@@ -103,7 +103,7 @@ export type WorktreeCreationRequest = {
note: string
/** Renderer-side launch plan used to seed the first terminal when the backend
* did not already spawn it. Null for blank-shell creates. */
startupPlan: AgentStartupPlan | null
startupPlan: ComposerAgentStartupPlan | null
quickPrompt: string
/** Launch context delivered only as an unsent TUI-input draft (argv prefill or
* startup paste); completion seeds the chat-composer copy from it. */
@@ -2,6 +2,7 @@ import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
planAgentCliArgsSuffix,
planLaunchPrompt,
type AgentStartupPlan
} from '@/lib/tui-agent-startup'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
@@ -14,6 +15,7 @@ import type { SessionOptionValue } from '../../../shared/native-chat-session-opt
export type SourceControlLaunchPlanDelivery =
| 'argv'
| 'argv-launch-file'
| 'draft-native'
| 'draft-paste'
| 'paste-submit'
@@ -43,6 +45,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
}): SourceControlLaunchPlanResult {
const agent = args.agent
if (!agent) {
@@ -133,7 +137,6 @@ export function planSourceControlAgentActionLaunch(args: {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null,
launchConfig: draftLaunchPlan.launchConfig,
...(draftLaunchPlan.sessionOptions
? { sessionOptions: draftLaunchPlan.sessionOptions }
@@ -172,7 +175,7 @@ export function planSourceControlAgentActionLaunch(args: {
})
delivery = 'draft-paste'
} else {
startupPlan = buildAgentStartupPlan({
const planned = planLaunchPrompt({
agent,
prompt: trimmedInput,
cmdOverrides,
@@ -181,9 +184,26 @@ export function planSourceControlAgentActionLaunch(args: {
isRemote,
agentArgs: args.agentArgs,
sessionOptions: args.sessionOptions,
allowEmptyPromptLaunch: false
launchHostIsPaired: args.launchHostIsPaired ?? false
})
delivery = 'argv'
switch (planned?.carry) {
case undefined:
delivery = 'argv'
break
case 'none':
case 'on-line':
startupPlan = planned.plan
delivery = 'argv'
break
case 'launch-file':
startupPlan = planned.plan
delivery = 'argv-launch-file'
break
case 'paste-after-ready':
startupPlan = planned.cleanPlan
delivery = 'paste-submit'
break
}
}
if (!startupPlan) {
@@ -203,7 +223,7 @@ export function planSourceControlAgentActionLaunch(args: {
? 'The command input is prefilled as an editable draft by the agent launch command.'
: delivery === 'draft-paste'
? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.'
: startupPlan.launchFile
: delivery === 'argv-launch-file'
? 'The command input goes in a private file the host writes; the launch command tells the agent to read it, submitted as the first turn.'
: 'The command input is included in the launch command and submitted as the first turn.'
+50 -38
View File
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
isShellProcess
} from './tui-agent-startup'
import { planLaunchForTest } from '../../../shared/launch-prompt-plan.test-fixture'
import { buildAgentDraftLaunchPlan, isShellProcess } from './tui-agent-startup'
import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults'
const emptyLaunchConfig = (agentCommand: string) => ({
@@ -15,7 +12,7 @@ const emptyLaunchConfig = (agentCommand: string) => ({
describe('buildAgentStartupPlan', () => {
it('passes Claude prompts as a positional interactive argument', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'claude',
prompt: 'Fix the bug',
cmdOverrides: {},
@@ -25,14 +22,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'claude',
launchCommand: "claude 'Fix the bug'",
expectedProcess: 'claude',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('claude')
})
})
it('uses Gemini interactive prompt mode instead of dropping the prompt', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'gemini',
prompt: 'Investigate this regression',
cmdOverrides: {},
@@ -42,14 +40,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'gemini',
launchCommand: "gemini --prompt-interactive 'Investigate this regression'",
expectedProcess: 'gemini',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('gemini')
})
})
it('uses Antigravity interactive prompt mode with the agy binary', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'antigravity',
prompt: 'Investigate this regression',
cmdOverrides: {},
@@ -59,14 +58,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'antigravity',
launchCommand: "agy --prompt-interactive 'Investigate this regression'",
expectedProcess: 'agy',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('agy')
})
})
it('launches aider first and injects the draft prompt after startup', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'aider',
prompt: 'Refactor the parser',
cmdOverrides: {},
@@ -76,14 +76,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'Refactor the parser',
carry: 'paste-after-ready',
pasteAfterReady: 'Refactor the parser',
launchConfig: emptyLaunchConfig('aider')
})
})
it('launches Autohand Code first and injects the draft prompt after startup', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'autohand',
prompt: 'Add tests for the parser',
cmdOverrides: {},
@@ -93,14 +94,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'autohand',
launchCommand: 'autohand',
expectedProcess: 'autohand',
followupPrompt: 'Add tests for the parser',
carry: 'paste-after-ready',
pasteAfterReady: 'Add tests for the parser',
launchConfig: emptyLaunchConfig('autohand')
})
})
it('launches Ante first and injects the draft prompt after startup', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'ante',
prompt: 'Summarize the failing tests',
cmdOverrides: {},
@@ -110,14 +112,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'ante',
launchCommand: 'ante',
expectedProcess: 'ante',
followupPrompt: 'Summarize the failing tests',
carry: 'paste-after-ready',
pasteAfterReady: 'Summarize the failing tests',
launchConfig: emptyLaunchConfig('ante')
})
})
it('passes the prompt to Trae as a positional argv behind a `--` separator', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'trae',
prompt: 'Summarize the failing tests',
cmdOverrides: {},
@@ -127,7 +130,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'trae',
launchCommand: "traecli -- 'Summarize the failing tests'",
expectedProcess: 'traecli',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('traecli')
})
})
@@ -135,7 +139,7 @@ describe('buildAgentStartupPlan', () => {
// Why: without the separator these dispatch to Trae's `help`/`config` subcommands instead.
it('keeps subcommand-shaped Trae prompts as the positional prompt', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'trae',
prompt: 'help me name this config',
cmdOverrides: {},
@@ -146,7 +150,7 @@ describe('buildAgentStartupPlan', () => {
it('delivers the Muse prompt after its composer is ready', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'muse',
prompt: 'Summarize the failing tests',
cmdOverrides: {},
@@ -156,7 +160,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'muse',
launchCommand: 'muse --trust-workspace',
expectedProcess: 'muse',
followupPrompt: 'Summarize the failing tests',
carry: 'paste-after-ready',
pasteAfterReady: 'Summarize the failing tests',
launchConfig: {
...emptyLaunchConfig('muse'),
agentCommand: 'muse --trust-workspace'
@@ -166,7 +171,7 @@ describe('buildAgentStartupPlan', () => {
it('passes the prompt to Prime Agent as a positional argv behind a `--` separator', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'prime-agent',
prompt: 'Summarize the failing tests',
cmdOverrides: {},
@@ -176,7 +181,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'prime-agent',
launchCommand: "prime-agent -- 'Summarize the failing tests'",
expectedProcess: 'prime-agent',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('prime-agent')
})
})
@@ -184,7 +190,7 @@ describe('buildAgentStartupPlan', () => {
// Why: without the separator these dispatch to Prime Agent's `help`/`agents` subcommands instead.
it('keeps subcommand-shaped Prime Agent prompts as the positional prompt', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'prime-agent',
prompt: 'help me name this config',
cmdOverrides: {},
@@ -195,7 +201,7 @@ describe('buildAgentStartupPlan', () => {
it('uses cursor-agent as the actual launch binary', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'cursor',
prompt: 'Review this file',
cmdOverrides: {},
@@ -205,14 +211,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'cursor',
launchCommand: "cursor-agent 'Review this file'",
expectedProcess: 'cursor-agent',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('cursor-agent')
})
})
it('applies command overrides without changing the prompt syntax contract', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'droid',
prompt: 'Ship the fix',
cmdOverrides: { droid: '/opt/factory/bin/droid' },
@@ -222,14 +229,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'droid',
launchCommand: "/opt/factory/bin/droid 'Ship the fix'",
expectedProcess: 'droid',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('/opt/factory/bin/droid')
})
})
it('passes Copilot prompts with the -i flag for an interactive session', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'copilot',
prompt: 'Fix the bug',
cmdOverrides: {},
@@ -239,14 +247,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'copilot',
launchCommand: "copilot -i 'Fix the bug'",
expectedProcess: 'copilot',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('copilot')
})
})
it('launches Grok with the prompt as a positional argv', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'grok',
prompt: 'Trace the failing test',
cmdOverrides: {},
@@ -256,14 +265,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'grok',
launchCommand: "grok -- 'Trace the failing test'",
expectedProcess: 'grok',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('grok')
})
})
it('launches Devin first and injects the prompt after startup', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'devin',
prompt: 'Trace the failing test',
cmdOverrides: {},
@@ -274,7 +284,8 @@ describe('buildAgentStartupPlan', () => {
agent: 'devin',
launchCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'",
expectedProcess: 'devin',
followupPrompt: 'Trace the failing test',
carry: 'paste-after-ready',
pasteAfterReady: 'Trace the failing test',
launchConfig: {
agentCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'",
agentArgs: '--permission-mode bypass --respect-workspace-trust false',
@@ -285,7 +296,7 @@ describe('buildAgentStartupPlan', () => {
it('launches Command Code by its unambiguous binary with a positional prompt', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'command-code',
prompt: 'Fix the issue',
cmdOverrides: {},
@@ -295,14 +306,15 @@ describe('buildAgentStartupPlan', () => {
agent: 'command-code',
launchCommand: "command-code --trust 'Fix the issue'",
expectedProcess: 'command-code',
followupPrompt: null,
carry: 'on-line',
pasteAfterReady: null,
launchConfig: emptyLaunchConfig('command-code --trust')
})
})
it('returns null when there is no prompt to inject', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'codex',
prompt: ' ',
cmdOverrides: {},
+4 -1
View File
@@ -3,6 +3,7 @@ export {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
planAgentCliArgsSuffix,
planLaunchPrompt,
isShellProcess,
quoteStartupArg,
resolveStartupShell
@@ -10,5 +11,7 @@ export {
export type {
AgentCliArgsPlan,
AgentDraftLaunchPlan,
AgentStartupPlan
AgentLaunchPromptArgs,
AgentStartupPlan,
AgentStartupPlanInputs
} from '../../../shared/tui-agent-startup'
@@ -40,7 +40,6 @@ function makeAgentRequest(): WorktreeCreationRequest {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
},
quickPrompt: '',
@@ -206,7 +206,6 @@ describe('a creation that finishes after the user moved on (#9944)', () => {
agent: 'claude',
launchCommand: 'claude',
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -28,6 +28,7 @@ export function buildWorktreeCreationStartupOpt(
...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}),
...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}),
...(plan.launchFile ? { launchFile: plan.launchFile } : {}),
...(plan.launchPrompt ? { launchPrompt: plan.launchPrompt } : {}),
// Why: command-code shows its prompt in the tab status before the first
// hook fires, so the prompt is threaded through here.
...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0
@@ -232,7 +232,7 @@ describe('a throw after createWorktree succeeds no longer strands the creation s
launchCommand: 'codex',
expectedProcess: 'codex',
draftPrompt: 'draft context',
followupPrompt: 'follow-up context',
pastePromptAfterReady: 'follow-up context',
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -626,7 +626,6 @@ describe('staged background worktree creation', () => {
agent: 'claude',
launchCommand: 'claude --prefill x',
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
},
launchDraftPrompt: 'https://github.com/o/r/issues/12'
@@ -664,7 +663,6 @@ describe('staged background worktree creation', () => {
agent: 'claude',
launchCommand: 'claude --prefill x',
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
},
launchDraftPrompt: 'https://github.com/o/r/issues/12'
@@ -697,7 +695,6 @@ describe('staged background worktree creation', () => {
agent,
launchCommand: `${agent} --prefill x`,
expectedProcess: agent,
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
},
launchDraftPrompt: draft
@@ -732,7 +729,6 @@ describe('staged background worktree creation', () => {
agent: 'claude',
launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'",
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
},
launchDraftPrompt: 'https://github.com/o/r/issues/12'
@@ -761,7 +757,6 @@ describe('staged background worktree creation', () => {
agent: 'claude',
launchCommand: 'claude',
expectedProcess: 'claude',
followupPrompt: null,
launchConfig: { agentArgs: '', agentEnv: {} }
}
})
@@ -789,7 +784,6 @@ describe('staged background worktree creation', () => {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agent: 'codex', command: 'codex' },
draftPrompt: 'ship it'
} as never
@@ -33,6 +33,8 @@ export type WorktreeStartupPayload = {
startupCommandDelivery?: StartupCommandDelivery
/** Written by the host before it types `command`, which names it by placeholder. */
launchFile?: LaunchFile
/** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */
launchPrompt?: string
initialAgentStatus?: { agent: TuiAgent; prompt: string }
sessionOptions?: Record<string, SessionOptionValue>
telemetry?: AgentStartedTelemetry
@@ -207,6 +207,7 @@ export type TerminalActions = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
launchFile?: LaunchFile
launchPrompt?: string
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
@@ -234,6 +235,7 @@ export type TerminalActions = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
launchFile?: LaunchFile
launchPrompt?: string
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
@@ -67,6 +67,7 @@ export type TerminalState = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
launchFile?: LaunchFile
launchPrompt?: string
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { planLaunchForTest } from './launch-prompt-plan.test-fixture'
import { runProcess } from './child-process/run-process'
import {
WINDOWS_ARGUMENT_CORPUS,
@@ -9,7 +10,6 @@ import {
} from './child-process/__fixtures__/windows-argument-corpus'
import { removeTreeSync } from './windows-transient-lock-removal'
import { quoteStartupArg } from './tui-agent-startup-shell'
import { buildAgentStartupPlan } from './tui-agent-startup'
import { removeLaunchFile, writeLaunchFile } from './launch-file-writing'
/**
@@ -64,7 +64,7 @@ describeOnWindows('cmd launch-line prompt quoting', () => {
it('never lets a multi-line prompt reach cmd as commands: the line names a launch file', async () => {
const marker = join(dir, 'pwned.txt')
const prompt = `Fix the build\r\n& echo PWNED> "${marker}"\nthen run the tests`
const plan = buildAgentStartupPlan({
const plan = planLaunchForTest({
agent: 'claude',
prompt,
cmdOverrides: { claude: shim },
@@ -97,7 +97,7 @@ describeOnWindows('cmd launch-line prompt quoting', () => {
it('names a launch file under a home with an apostrophe, accents, CJK and `%`', async () => {
const home = join(dir, "O'Brien José 张伟 100%")
mkdirSync(home)
const plan = buildAgentStartupPlan({
const plan = planLaunchForTest({
agent: 'claude',
prompt: 'Fix the build\nthen run the tests',
cmdOverrides: { claude: shim },
+6 -5
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup'
import { planLaunchForTest } from './launch-prompt-plan.test-fixture'
import { buildAgentDraftLaunchPlan } from './tui-agent-startup'
import { isExpectedAgentProcess, recognizeAgentProcess } from './agent-process-recognition'
import { pickTuiAgent } from './tui-agent-selection'
@@ -8,7 +9,7 @@ describe('Freebuff terminal launches', () => {
'delivers task text through the interactive terminal on %s',
(platform) => {
const prompt = '--help\nFix "quoted" paths and $HOME handling'
const plan = buildAgentStartupPlan({
const plan = planLaunchForTest({
agent: 'freebuff',
prompt,
cmdOverrides: {},
@@ -17,7 +18,7 @@ describe('Freebuff terminal launches', () => {
expect(plan).toMatchObject({
launchCommand: 'freebuff',
expectedProcess: 'freebuff',
followupPrompt: prompt
pasteAfterReady: prompt
})
expect(
buildAgentDraftLaunchPlan({
@@ -32,14 +33,14 @@ describe('Freebuff terminal launches', () => {
it('uses the same launch contract on SSH hosts', () => {
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'freebuff',
prompt: 'Inspect this folder',
cmdOverrides: {},
platform: 'linux',
isRemote: true
})
).toMatchObject({ launchCommand: 'freebuff', followupPrompt: 'Inspect this folder' })
).toMatchObject({ launchCommand: 'freebuff', pasteAfterReady: 'Inspect this folder' })
})
it.each(['/usr/local/bin/freebuff', 'C:\\tools\\freebuff.exe'])(
+1 -2
View File
@@ -23,8 +23,7 @@ describe('Freebuff startup', () => {
expect(freebuff).toMatchObject({
launchCommand: 'freebuff --debug',
expectedProcess: 'freebuff',
followupPrompt: null
expectedProcess: 'freebuff'
})
expect(codebuff).toMatchObject({ launchCommand: 'codebuff', expectedProcess: 'codebuff' })
})
@@ -2,18 +2,18 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { planLaunchForTest } from './launch-prompt-plan.test-fixture'
import { writeLaunchFile } from './launch-file-writing'
import {
buildLaunchFilePointer,
carryInLaunchFile,
launchFileDirectoryPlaceholder
} from './launch-prompt-file'
import { buildAgentStartupPlan } from './tui-agent-startup'
import type { TuiAgent } from './tui-agent'
function planWithLaunchFile(agent: TuiAgent, shell: 'posix' | 'cmd' | 'powershell' = 'posix') {
const { prompt, launchFile } = carryInLaunchFile('the brief', true)
const plan = buildAgentStartupPlan({
const plan = planLaunchForTest({
agent,
prompt,
cmdOverrides: {},
@@ -53,7 +53,7 @@ describe('the launch-file directory grant on the agent command line', () => {
const { plan, pointer } = planWithLaunchFile('codex')
expect(plan?.launchCommand).toBe(`codex '${pointer}'`)
expect(
buildAgentStartupPlan({
planLaunchForTest({
agent: 'claude',
prompt: 'fix it',
cmdOverrides: {},
+19 -29
View File
@@ -20,12 +20,7 @@ import {
sweepStaleLaunchFiles,
writeLaunchFile
} from './launch-file-writing'
import {
MAX_INLINE_LAUNCH_PROMPT_CHARS,
buildLaunchFilePointer,
parseLaunchFile,
planLaunchPrompt
} from './launch-prompt-file'
import { buildLaunchFilePointer, carryInLaunchFile, parseLaunchFile } from './launch-prompt-file'
import { quoteStartupArg } from './tui-agent-startup-shell'
let baseDirectory: string
@@ -38,35 +33,30 @@ afterEach(() => {
rmSync(baseDirectory, { recursive: true, force: true })
})
describe('planLaunchPrompt', () => {
it('keeps a prompt at the ceiling inline', () => {
const prompt = 'a'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)
expect(planLaunchPrompt(prompt)).toEqual({ prompt })
})
it('moves a 20,000-character prompt into a launch file', () => {
describe('carryInLaunchFile', () => {
it('moves a prompt into a launch file and names it by a pointer', () => {
const prompt = 'b'.repeat(20_000)
const planned = planLaunchPrompt(prompt)
expect(planned.launchFile).toMatchObject({ content: prompt, sensitive: false })
expect(planned.prompt).toBe(buildLaunchFilePointer(planned.launchFile!.placeholder))
const carried = carryInLaunchFile(prompt, false)
expect(carried.launchFile).toMatchObject({ content: prompt, sensitive: false })
expect(carried.prompt).toBe(buildLaunchFilePointer(carried.launchFile.placeholder))
})
it('moves a short sensitive prompt into a launch file', () => {
const planned = planLaunchPrompt('token dcap_secret', { sensitive: true })
expect(planned.prompt).not.toContain('dcap_secret')
expect(planned.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true })
it('keeps a sensitive prompt off the pointer', () => {
const carried = carryInLaunchFile('token dcap_secret', true)
expect(carried.prompt).not.toContain('dcap_secret')
expect(carried.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true })
})
it('mints a fresh placeholder each time', () => {
const first = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder
const second = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder
const first = carryInLaunchFile('x', true).launchFile.placeholder
const second = carryInLaunchFile('x', true).launchFile.placeholder
expect(first).not.toBe(second)
})
})
describe('parseLaunchFile', () => {
it('accepts what planLaunchPrompt mints and rejects anything else', () => {
const { launchFile } = planLaunchPrompt('x', { sensitive: true })
it('accepts what carryInLaunchFile mints and rejects anything else', () => {
const { launchFile } = carryInLaunchFile('x', true)
expect(parseLaunchFile(launchFile)).toEqual(launchFile)
expect(parseLaunchFile({ ...launchFile, placeholder: 'HOME' })).toBeUndefined()
expect(parseLaunchFile({ ...launchFile, sensitive: 'yes' })).toBeUndefined()
@@ -81,7 +71,7 @@ describe('parseLaunchFile', () => {
describe('writeLaunchFile', () => {
it('writes a 0600 file in a private 0700 directory and puts its path in the line and env', () => {
const { prompt, launchFile } = planLaunchPrompt('c'.repeat(20_000))
const { prompt, launchFile } = carryInLaunchFile('c'.repeat(20_000), false)
const written = writeLaunchFile({
launchFile: launchFile!,
command: `claude '${prompt}'`,
@@ -102,7 +92,7 @@ describe('writeLaunchFile', () => {
it("refuses an apostrophe in the path when the line's quoting is unknown, leaving nothing behind", () => {
const quoted = join(baseDirectory, "it's")
mkdirSync(quoted)
const { launchFile } = planLaunchPrompt('x', { sensitive: true })
const { launchFile } = carryInLaunchFile('x', true)
expect(() => writeLaunchFile({ launchFile: launchFile!, baseDirectory: quoted })).toThrow(
LaunchFileUnavailableError
)
@@ -110,7 +100,7 @@ describe('writeLaunchFile', () => {
})
it('refuses when the temp directory cannot be written', () => {
const { launchFile } = planLaunchPrompt('x', { sensitive: true })
const { launchFile } = carryInLaunchFile('x', true)
expect(() =>
writeLaunchFile({ launchFile: launchFile!, baseDirectory: join(baseDirectory, 'missing') })
).toThrow(
@@ -119,7 +109,7 @@ describe('writeLaunchFile', () => {
})
it('removes the whole directory', () => {
const { launchFile } = planLaunchPrompt('x', { sensitive: true })
const { launchFile } = carryInLaunchFile('x', true)
const written = writeLaunchFile({ launchFile: launchFile!, baseDirectory })
removeLaunchFile(written)
expect(existsSync(written.directory)).toBe(false)
@@ -178,7 +168,7 @@ describePosix('a launch line naming a file under an unusual home directory', ()
async (user) => {
const home = join(baseDirectory, user)
mkdirSync(home)
const { prompt, launchFile } = planLaunchPrompt('brief', { sensitive: true })
const { prompt, launchFile } = carryInLaunchFile('brief', true)
const written = writeLaunchFile({
launchFile: { ...launchFile!, quoting: 'posix' },
command: `printf '%s' ${quoteStartupArg(prompt, 'posix')}`,
+25 -8
View File
@@ -20,7 +20,7 @@ import {
type LaunchFile
} from './launch-prompt-file'
import { quoteStartupArg, type AgentStartupShell } from './tui-agent-startup-shell'
import { typedStartupLineFits } from './typed-startup-line'
import { startupLineNeedsStaging } from './startup-command-staging'
import type { WslLaunchDirectory } from './wsl-launch-directory'
const LAUNCH_FILE_DIR_PREFIX = 'orca-launch-file-'
@@ -69,8 +69,8 @@ export function launchFilePathInQuotedRun(
}
export class LaunchFileUnavailableError extends Error {
constructor(reason: string) {
super(describeLaunchFileUnavailable(reason))
constructor(reason: string, carrier: 'file' | 'staged-line' = 'file') {
super(describeLaunchFileUnavailable(reason, carrier))
}
}
@@ -161,6 +161,19 @@ export function writeLaunchFile(args: {
}
}
/** Whether a WSL spawn needs its distro's launch directory: for a launch file, or to stage the
* long or multi-line agent line Orca built. Only then is the distro probed. */
export function spawnNeedsWslLaunchDirectory(args: {
launchFile?: LaunchFile
command?: string
orcaBuiltLine: boolean
}): boolean {
return (
args.launchFile !== undefined ||
(args.orcaBuiltLine && args.command !== undefined && startupLineNeedsStaging(args.command))
)
}
/**
* The write site's launch file for a spawn. A WSL session writes it into the distro, and refuses
* when the distro directory is unknown and a file or a staged line needs it: a Windows path means
@@ -175,11 +188,15 @@ export function writeSpawnLaunchFile(args: {
wslDirectory: WslLaunchDirectory | undefined
}): WrittenLaunchFile | undefined {
const wslDirectory = args.wslDistro ? args.wslDirectory : undefined
const needsDirectory =
args.launchFile !== undefined ||
(args.orcaBuiltLine && args.command !== undefined && !typedStartupLineFits(args.command))
if (args.wslDistro && needsDirectory && wslDirectory?.distro !== args.wslDistro) {
throw new LaunchFileUnavailableError("the WSL distro's home directory could not be reached")
if (
args.wslDistro &&
spawnNeedsWslLaunchDirectory(args) &&
wslDirectory?.distro !== args.wslDistro
) {
throw new LaunchFileUnavailableError(
"the WSL distro's home directory could not be reached",
args.launchFile ? 'file' : 'staged-line'
)
}
return args.launchFile
? writeLaunchFile({
+22 -14
View File
@@ -1,43 +1,51 @@
import { describe, expect, it } from 'vitest'
import { buildAgentStartupPlan } from './tui-agent-startup'
import { planLaunchForTest } from './launch-prompt-plan.test-fixture'
const base = { agent: 'claude' as const, cmdOverrides: {} }
describe('the one launch-prompt decision every launch path builds through', () => {
// Why: the composer, phone quick commands and background sessions call the builder directly.
it('points a single-line prompt past cmd’s line cap at a launch file', () => {
it('points a single-line Windows prompt past the typed budget at a launch file', () => {
const prompt = 'y'.repeat(9_000)
const plan = buildAgentStartupPlan({ ...base, prompt, platform: 'win32', shell: 'cmd' })
const plan = planLaunchForTest({ ...base, prompt, platform: 'win32', shell: 'cmd' })
expect(plan?.launchFile?.content).toBe(prompt)
expect(plan?.launchCommand.length).toBeLessThan(8_191)
})
it('points a prompt past the argv ceiling at a launch file on a POSIX host too', () => {
const prompt = 'z'.repeat(20_000)
expect(buildAgentStartupPlan({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe(
expect(planLaunchForTest({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe(
prompt
)
})
it('keeps a sensitive prompt off the line however short it is', () => {
const plan = buildAgentStartupPlan({
it('keeps a paired host to the typed budget, pasting what it may neither stage nor read', () => {
const short = planLaunchForTest({
...base,
prompt: 'token dcap_secret',
prompt: 'fix it',
platform: 'linux',
sensitive: true
launchHostIsPaired: true
})
expect(plan?.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true })
expect(plan?.launchCommand).not.toContain('dcap_secret')
expect(short?.carry).toBe('on-line')
const long = planLaunchForTest({
...base,
prompt: 'fix it\nthen run the tests',
platform: 'linux',
launchHostIsPaired: true
})
expect(long?.carry).toBe('paste-after-ready')
expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests')
expect(long?.launchCommand).not.toContain('run the tests')
})
// Why: the host writes a WSL session's staged line and launch file into the distro.
it('plans a WSL launch like any Linux launch: long lines stay typed, huge ones get a file', () => {
const typed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: 'one\ntwo' })
const typed = planLaunchForTest({ ...base, platform: 'linux', prompt: 'one\ntwo' })
expect(typed?.launchCommand).toContain('two')
expect(typed?.followupPrompt).toBeNull()
expect(typed?.pasteAfterReady).toBeNull()
const huge = 'c'.repeat(20_000)
const pointed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: huge })
const pointed = planLaunchForTest({ ...base, platform: 'linux', prompt: huge })
expect(pointed?.launchFile?.content).toBe(huge)
expect(pointed?.followupPrompt).toBeNull()
expect(pointed?.pasteAfterReady).toBeNull()
})
})
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest'
import { MAX_INLINE_LAUNCH_PROMPT_CHARS, carryInLaunchFile } from './launch-prompt-file'
import { planLaunchPrompt, type AgentLaunchPromptArgs } from './tui-agent-startup'
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'
function plan(
agent: TuiAgent,
prompt: string,
extra: Partial<Omit<AgentLaunchPromptArgs, 'agent' | 'prompt'>> = {}
) {
return planLaunchPrompt({ agent, prompt, cmdOverrides: {}, platform: 'darwin', ...extra })
}
describe('where a launch prompt rides', () => {
it('builds a plain launch for an empty prompt', () => {
expect(plan('claude', ' ')).toMatchObject({ carry: 'none' })
})
it('carries a short prompt on the line', () => {
const planned = plan('claude', 'explain this repo')
expect(planned?.carry).toBe('on-line')
expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain(
'explain this repo'
)
})
it.each([
['a 600-byte', 'x'.repeat(600)],
['a multi-line', 'first line\nsecond line'],
['a key-bearing', 'see\tthis \x1b[31mred']
])('carries %s prompt on the line of a POSIX host, which stages it', (_label, prompt) => {
const planned = plan('codex', prompt)
expect(planned?.carry).toBe('on-line')
expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain(prompt)
})
it.each<TuiAgent>(['claude', 'codex'])(
'points %s at a launch file past the argv ceiling, with the full text in the file',
(agent) => {
const prompt = `${'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)}z`
const planned = plan(agent, prompt)
if (planned?.carry !== 'launch-file') {
throw new Error(`expected a launch file, got ${planned?.carry}`)
}
expect(planned.launchFile).toMatchObject({ content: prompt, sensitive: false })
expect(planned.plan.launchCommand).toContain(planned.launchFile.placeholder)
expect(planned.plan.launchCommand).not.toContain('yyyy')
}
)
// The bug class this outcome removes: a plan that exists but does not carry the prompt.
it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => {
const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1)
const planned = plan('gemini', prompt)
if (planned?.carry !== 'paste-after-ready') {
throw new Error(`expected the paste, got ${planned?.carry}`)
}
expect(planned.text).toBe(prompt)
expect(planned.cleanPlan.launchCommand).not.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' })
})
it('names a launch file its caller wrote, quoted for the line', () => {
const { prompt, launchFile } = carryInLaunchFile('worker brief', true)
const planned = plan('claude', prompt, { launchFile })
expect(planned).toMatchObject({
carry: 'launch-file',
launchFile: { ...launchFile, quoting: 'posix' }
})
})
})
describe('a host that types the line raw', () => {
it.each<['cmd' | 'powershell']>([['cmd'], ['powershell']])(
'keeps a %s line to the typed budget 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')
}
)
it('points Codex at a file for a Windows-damaged prompt, and pastes it for Gemini', () => {
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'
)
})
it('pastes on a paired host what its line cannot carry typed, even for Claude', () => {
const extra = { platform: 'linux' as const, launchHostIsPaired: 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')
})
})
describe('whether a line can be typed as it is', () => {
it('holds a line to half of macOS MAX_CANON', () => {
expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES))).toBe(true)
expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES + 1))).toBe(false)
})
it.each(['\n', '\r', '\t', '\x1b', '\x03', '\x7f'])('never types a line with %j', (byte) => {
expect(typedStartupLineFits(`a${byte}b`)).toBe(false)
})
})
describe('the capability clients gate a prompted launch on', () => {
it('is advertised by every host that delivers a prompt its typed line cannot carry as typed', () => {
// An older host types any prompt into the line, so its absence is the gate.
expect(RUNTIME_CAPABILITIES).toContain(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY)
})
})
+167
View File
@@ -0,0 +1,167 @@
/** Where a launch prompt rides: decided once here, for every launch path, and switched on by each. */
import {
MAX_INLINE_LAUNCH_PROMPT_CHARS,
carryInLaunchFile,
launchFileDirectoryPlaceholder,
type LaunchFile
} from './launch-prompt-file'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
isPosixStartupShell,
quoteStartupArg,
resolveStartupShell,
type AgentStartupShell
} from './tui-agent-startup-shell'
import { hasControlByte, typedStartupLineFits } from './typed-startup-line'
import type { TuiAgent } from './tui-agent'
/**
* Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control
* byte literal: cmd types the line and a line break submits it early. PowerShell runs a short line
* from its argv, so its damage is the hand-off to the agent (measured): legacy argument passing (5.1
* always, 7.x into a `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a
* shim's cmd.exe expands `%NAME%`; a lone `%` stays literal.
*/
export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean {
if (isPosixStartupShell(shell)) {
return false
}
return (
hasControlByte(prompt) ||
(shell === 'powershell' &&
(prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\')))
)
}
/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */
export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null {
return windowsShellDamagesPrompt(draft.trim(), shell)
? "The host's Windows shell would break this draft on the agent's command line (it has a line " +
'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' +
'trailing backslash), so the agent was not started. Start it without the draft and paste ' +
'the draft once it opens.'
: null
}
/**
* Where a launch prompt went. Only the carried outcomes hold the plan to launch, so a caller must
* switch on `carry` before it can launch anything, and cannot mistake a paste for a delivery.
*/
export type LaunchPromptPlan<P> =
| { carry: 'none'; plan: P }
| { carry: 'on-line'; plan: P }
| { carry: 'launch-file'; plan: P; launchFile: LaunchFile }
| { carry: 'paste-after-ready'; cleanPlan: P; text: string }
export type LaunchPromptCarry = LaunchPromptPlan<unknown>['carry']
/** Why a create that cannot paste after the agent is ready refuses a prompt that needs it. */
export function launchPromptNeedsPasteRefusal(
agent: TuiAgent,
created: 'terminal' | 'session'
): string {
return TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
? `${agent} takes its prompt only after it starts, so no ${created} was created. Start the ` +
'agent and paste the prompt once it opens.'
: `${agent} cannot take this prompt on its command line here (too long, or broken by the ` +
`host's Windows shell), so no ${created} was created. Start the agent without it and ` +
'paste the prompt once it opens.'
}
export type CarriedPlanArgs = {
agent: TuiAgent
prompt: string
platform: NodeJS.Platform
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
}
export function agentReadsLaunchFile(agent: TuiAgent): boolean {
return TUI_AGENT_CONFIG[agent].readsLaunchFile === true
}
/**
* 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.
*/
export function carryLaunchPrompt<A extends CarriedPlanArgs, P extends { launchCommand: string }>(
args: A,
buildLine: (args: A) => P | null
): LaunchPromptPlan<P> | null {
const text = args.prompt.trim()
const shell = resolveStartupShell(args.platform, args.shell)
const clean = (): P | null => buildLine({ ...args, prompt: '', launchFile: undefined })
if (!text) {
const plan = clean()
return plan && { carry: 'none', plan }
}
if (args.launchFile) {
const plan = buildLine(args)
return plan && { carry: 'launch-file', plan, launchFile: withQuoting(args.launchFile, shell) }
}
const pasteAfterReady = (): LaunchPromptPlan<P> | null => {
const cleanPlan = clean()
return cleanPlan && { carry: 'paste-after-ready', cleanPlan, text }
}
const mode = TUI_AGENT_CONFIG[args.agent].promptInjectionMode
if (mode === 'stdin-after-start') {
return 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)) {
return pasteAfterReady()
}
const pointer = carryInLaunchFile(text, false)
const plan = buildLine({ ...args, prompt: pointer.prompt, launchFile: pointer.launchFile })
return (
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))
) {
return viaLaunchFile()
}
const plan = buildLine(args)
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)) {
return viaLaunchFile()
}
return { carry: 'on-line', plan }
}
/** The host writes the path inside this line's quoting, so the file carries which one it is. */
function withQuoting(launchFile: LaunchFile, shell: AgentStartupShell): LaunchFile {
return { ...launchFile, quoting: shell }
}
/** The host puts the launch file's private directory where the placeholder is, like its path. */
export function launchFileDirectoryGrant(
agent: TuiAgent,
launchFile: LaunchFile | undefined,
shell: AgentStartupShell
): string {
const flag = TUI_AGENT_CONFIG[agent].launchFileDirectoryFlag
if (!launchFile || !flag) {
return ''
}
return ` ${quoteStartupArg(`${flag}=${launchFileDirectoryPlaceholder(launchFile.placeholder)}`, shell)}`
}
+11 -17
View File
@@ -49,20 +49,6 @@ export function isLaunchFilePointer(prompt: string): boolean {
)
}
/**
* The prompt a launch line carries, and the file behind it when the prompt is too
* long or sensitive. The pointer names a placeholder the execution host replaces.
*/
export function planLaunchPrompt(
prompt: string,
options: { sensitive?: boolean } = {}
): { prompt: string; launchFile?: LaunchFile } {
const sensitive = options.sensitive === true
return sensitive || prompt.length > MAX_INLINE_LAUNCH_PROMPT_CHARS
? carryInLaunchFile(prompt, sensitive)
: { prompt }
}
export function carryInLaunchFile(
content: string,
sensitive: boolean
@@ -103,9 +89,17 @@ export function parseLaunchFile(value: unknown): LaunchFile | undefined {
const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable'
/** The refusal a host sends when it could not write a launch file; it reaches the user as is. */
export function describeLaunchFileUnavailable(reason: string): string {
return `Orca could not write the file that carries the agent's prompt (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]`
/** 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. */
export function describeLaunchFileUnavailable(
reason: string,
carrier: 'file' | 'staged-line' = 'file'
): string {
const what =
carrier === 'file'
? "the file that carries the agent's prompt"
: "the script that carries the agent's launch line and prompt"
return `Orca could not write ${what} (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]`
}
/** Also matches the `launch_file_unavailable: <reason>` form earlier builds sent. */

Some files were not shown because too many files have changed in this diff Show More