feat(agent-launch): host-side prompt delivery for agent.launch

The host's agent.launch typed any launch prompt into the shell as part of the
launch command. A long or multi-line prompt then ran line by line in the
shell, and an agent that never showed readiness or crashed at startup had
nothing guarding where its text went.

agent.launch now carries a prompt on the typed line only when the line stays
one line, control-free and at most 512 bytes; otherwise the agent starts
clean and the host pastes the prompt once the agent's own ready signal fires
(bracketed paste plus its composer marker or a quiet render, read only after
the shell's last hand-off, never while the pane's own shell is proven in
front), with main's draft-paste bytes and an Enter 50 ms later. Orchestration
worker starts wait on tui-idle as before. A replay-safe launch admits and
claims its ledger row in one write, Qwen Code gets a second Enter, the
desktop and phone share one launch-refusal classifier, and hosts advertise
agent.launch.prompt-carry.v1.

Split out of #23748, which moves the desktop source-control buttons onto
this path.
This commit is contained in:
Brennan Benson
2026-09-30 18:34:51 -07:00
parent b594898533
commit d2680896f7
76 changed files with 3248 additions and 276 deletions
@@ -7,19 +7,17 @@
*/
import type { AgentLaunchPrompt, AgentLaunchResult } from '../../../src/shared/agent-launch-intent'
import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../src/shared/agent-launch-pane-already-live'
import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../src/shared/agent-launch-session-already-exists'
import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
import { makePaneKey } from '../../../src/shared/stable-pane-id'
import { createStructuredAgentSessionId } from '../../../src/shared/structured-agent-session-create'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { RpcClient } from '../transport/rpc-client'
import { agentLaunchReplayRun } from '../tasks/mobile-workspace-create-operations'
import { agentLaunchExistingParams, readAgentLaunchSupport } from '../tasks/agent-launch-request'
import {
agentLaunchExistingParams,
isAgentLaunchReplayUnsupportedRefusal,
readAgentLaunchSupport
} from '../tasks/agent-launch-request'
classifyAgentLaunchReplayRefusal,
isAgentLaunchReservationTakenRefusal
} from '../../../src/shared/agent-launch-replay-refusal'
import { sendReplayingAmbiguousDelivery } from '../tasks/replay-on-ambiguous-delivery'
import {
structuredSessionOperationId,
@@ -152,29 +150,17 @@ function classifyLaunchRefusal(
error: { code?: string; message?: string },
replayed: boolean
): MobileExistingAgentLaunch {
if (isAgentLaunchReplayUnsupportedRefusal(error)) {
// Only a refusal of the first send proves nothing ran; after a replay it may be a replacement
// connection whose capability list hasn't landed, answering for an attempt that did start.
return replayed
? { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE }
: { kind: 'unsupported' }
switch (classifyAgentLaunchReplayRefusal(error, replayed)) {
case 'unsupported':
return { kind: 'unsupported' }
case 'unknown':
return { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE }
case 'failed': {
if (isAgentLaunchReservationTakenRefusal(error)) {
return { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE }
}
const message = error.message?.trim()
return { kind: 'failed', message: message || "Couldn't start the agent." }
}
}
if (
error.code === 'agent_session_operation_unknown' ||
error.code === 'agent_session_operation_expired'
) {
return { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE }
}
if (
error.code === AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE ||
error.code === AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
) {
// A taken reservation proves nothing started only on the first send; after a replay the pane
// or chat holding it may be this launch's own.
return replayed
? { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE }
: { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE }
}
const message = error.message?.trim()
return { kind: 'failed', message: message || "Couldn't start the agent." }
}
+2 -8
View File
@@ -142,11 +142,5 @@ export function isAgentLaunchUnsupportedRefusal(error: {
return (error.message ?? '').includes('agent_launch_unsupported')
}
/** The `agent.launchReplay` twin: an older host rejects the method rather than a field. */
export function isAgentLaunchReplayUnsupportedRefusal(error: { code?: string }): boolean {
return (
error.code === 'method_not_found' ||
error.code === 'forbidden' ||
error.code === 'agent_launch_replay_unsupported'
)
}
// The `agent.launchReplay` twin lives in shared so the desktop classifies refusals the same way.
export { isAgentLaunchReplayUnsupportedRefusal } from '../../../src/shared/agent-launch-replay-refusal'
@@ -5,6 +5,6 @@
# 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 1 attributes
src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes
src/main/runtime/runtime-worktree-agent-startup.ts 4 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
@@ -27,8 +27,12 @@ function harness(options: {
structuredCreateError?: Error
deliveredMessageId?: string | null
terminalPromptDelivered?: boolean
/** Whether the surface reports that its typed line took the offered prompt. */
lineCarriesPrompt?: boolean
}) {
const calls: string[] = []
const carried = (startupPrompt: string | undefined) =>
startupPrompt && (options.lineCarriesPrompt ?? true) ? { promptRodeLaunchCommand: true } : {}
const createWorktree = vi.fn(
async (args: {
create: Record<string, unknown>
@@ -38,7 +42,8 @@ function harness(options: {
calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`)
return {
worktreeId: 'wt-new',
startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined
startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined,
...carried(args.startupPrompt)
}
}
)
@@ -56,9 +61,9 @@ function harness(options: {
}
return { sessionId: 'sess-1', handle: 'handle_structured', fence: 4 }
})
const createTerminalAgent = vi.fn(async (_args: { startupPrompt?: string }) => {
const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => {
calls.push('createTerminalAgent')
return { handle: 'term_1' }
return { handle: 'term_1', ...carried(args.startupPrompt) }
})
const deliverStructuredPrompt = vi.fn(async () => {
calls.push('deliverStructuredPrompt')
@@ -297,10 +302,10 @@ describe('the prompt receipt', () => {
})
/**
* A terminal takes its prompt one of two ways, and which one is not a preference: an agent whose
* CLI accepts a prompt argument must get it on argv, because that is the transport that survives
* multi-line and special-character text. Only an agent with no such argument is written to as
* keystrokes. `claude` is argv-mode, `aider` is `stdin-after-start` — the two halves of the table.
* A terminal takes its prompt one of two ways. An agent whose CLI accepts a prompt argument is
* offered it on the launch command, and the surface that types that line reports whether it rode;
* everything else is written as keystrokes once the agent is ready. `claude` is argv-mode, `aider`
* is `stdin-after-start` — the two halves of the table.
*/
describe('delivering a launch prompt to a terminal agent', () => {
const SUBMIT = { text: 'do the thing', delivery: 'submit' } as const
@@ -329,6 +334,8 @@ describe('delivering a launch prompt to a terminal agent', () => {
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({
handle: 'term_1',
agent: 'aider',
freshLaunch: true,
prompt: SUBMIT
})
// Folding it into argv would have appended it as an argument the CLI does not accept.
@@ -348,6 +355,39 @@ describe('delivering a launch prompt to a terminal agent', () => {
expect(h.deliverTerminalPrompt).not.toHaveBeenCalled()
})
it('pastes an argv agent’s prompt after start when the surface reports its typed line could not carry it', async () => {
const h = harness({
createSupport: { supported: false, reason: 'wsl' },
lineCarriesPrompt: false
})
const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT })
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
// Offered to the launch command; the surface, not the executor, decided it did not ride.
expect(h.createTerminalAgent.mock.calls[0]?.[0]).toMatchObject({
startupPrompt: 'do the thing'
})
expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
prompt: SUBMIT
})
})
it('pastes into an agent-first create’s startup terminal when its typed line could not carry the prompt', async () => {
const h = harness({ settings: null, lineCarriesPrompt: false })
const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT })
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({
handle: 'term_agent_first',
agent: 'claude',
freshLaunch: true,
prompt: SUBMIT
})
})
it('writes into a reused terminal, whose process started before the launch existed', async () => {
const h = harness({})
const result = await h.run({
@@ -361,6 +401,8 @@ describe('delivering a launch prompt to a terminal agent', () => {
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({
handle: 'term_existing',
agent: 'claude',
freshLaunch: false,
prompt: SUBMIT
})
})
+37 -13
View File
@@ -70,8 +70,12 @@ export type AgentLaunchExecution = {
vocabulary?: AgentLaunchModeVocabulary
/** Attributes a throw to the step that was running, the way a dispatch's own stages do. */
onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void
/** The surface exists and its tab is published; runs before any prompt delivery. Must not throw. */
onSurfacePublished?: (surface: AgentLaunchPublishedSurface) => void
}
export type AgentLaunchPublishedSurface = Pick<AgentLaunchResult, 'outcome' | 'worktreeId'>
export async function executeAgentLaunch(
execution: AgentLaunchExecution
): Promise<AgentLaunchResult> {
@@ -95,13 +99,18 @@ export async function executeAgentLaunch(
// A reused terminal already downgraded in the pre-flight; there is nothing to create. Its agent
// was running before this launch existed, so argv is unreachable and the PTY is the only way in.
if (intent.reuseTerminal) {
return {
const reused = published(execution, {
outcome: { kind: 'terminal', handle: intent.reuseTerminal.handle },
worktreeId: existingWorktreeId(intent.target),
worktreeId: existingWorktreeId(intent.target)
})
return {
...reused,
receipt: preflight,
...promptReceipt(
intent,
await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle)
await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, {
freshLaunch: false
})
)
}
}
@@ -109,20 +118,25 @@ export async function executeAgentLaunch(
const placed = await resolveWorkspace(execution, preflight)
// Agent-first creation already produced the agent, so the pre-flight verdict is final.
if (placed.startupTerminalHandle) {
return {
const startup = published(execution, {
outcome: {
kind: 'terminal',
handle: placed.startupTerminalHandle,
...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {})
},
worktreeId: placed.worktreeId,
worktreeId: placed.worktreeId
})
return {
...startup,
receipt: preflight,
...(placed.warning ? { warning: placed.warning } : {}),
...promptReceipt(
intent,
placed.promptRodeLaunchCommand
? HANDED_TO_TERMINAL
: await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle)
: await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, {
freshLaunch: true
})
)
}
}
@@ -165,15 +179,23 @@ export async function executeAgentLaunch(
// not start while looking at it. Telling those apart needs `createManagedWorktree` to stop
// multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string.
const warning = combineLaunchWarnings(placed.warning, created.warning)
const surface = published(execution, { outcome: created.outcome, worktreeId: placed.worktreeId })
return {
outcome: created.outcome,
worktreeId: placed.worktreeId,
...surface,
receipt: settled,
...(warning ? { warning } : {}),
...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created))
}
}
function published(
execution: AgentLaunchExecution,
surface: AgentLaunchPublishedSurface
): AgentLaunchPublishedSurface {
execution.onSurfacePublished?.(surface)
return surface
}
function downgradeAgentLaunchModeForStructuredRefusal(
receipt: AgentLaunchModeReceipt,
vocabulary: AgentLaunchModeVocabulary
@@ -219,9 +241,10 @@ async function resolveWorkspace(
})
// Only when a startup terminal actually came back: a create that produced none ran no command,
// so nothing carried the prompt and the launch still owes it to whatever surface it builds next.
return created.startupTerminalHandle && startupPrompt
? { ...created, promptRodeLaunchCommand: true }
: created
const { promptRodeLaunchCommand, ...rest } = created
return rest.startupTerminalHandle && promptRodeLaunchCommand
? { ...rest, promptRodeLaunchCommand: true }
: rest
}
/** `structured` is the same surface `outcome` names, kept typed so prompt delivery reads the create's
@@ -314,7 +337,8 @@ async function createTerminalSurface(
worktreeId,
agent: intent.agent,
...(startupPrompt ? { startupPrompt } : {}),
...terminalLaunchInputs(intent)
...terminalLaunchInputs(intent),
...(intent.viewMode ? { viewMode: intent.viewMode } : {})
})
return {
outcome: {
@@ -323,7 +347,7 @@ async function createTerminalSurface(
...(terminal.paneKey ? { paneKey: terminal.paneKey } : {})
},
...(terminal.warning ? { warning: terminal.warning } : {}),
...(startupPrompt ? { promptRodeLaunchCommand: true } : {})
...(startupPrompt && terminal.promptRodeLaunchCommand ? { promptRodeLaunchCommand: true } : {})
}
}
@@ -5,16 +5,15 @@
* surface exists and in what order; everything here decides how the text reaches whichever surface
* that turned out to be, and each surface takes it differently:
*
* structured session -> committed to the transcript, named by a message id -> journaled
* terminal, argv CLI -> folded into the command that execs the agent -> handed-to-terminal
* terminal, no argv -> bracketed paste into the live PTY -> handed-to-terminal
* anything unproven -> -> not-delivered
* structured session -> committed to the transcript, named by a message id -> journaled
* terminal, line fits -> folded into the command that execs the agent -> handed-to-terminal
* terminal, otherwise -> bracketed paste into the live PTY once it is ready -> handed-to-terminal
* anything unproven -> -> not-delivered
*
* The argv/PTY fork is not a preference. `argv` exists so multi-line and special-character text
* reaches a CLI as one argument instead of keystrokes, and it has no readiness race because the
* text is in the process's arguments at exec time. So it is preferred wherever the agent's CLI
* takes a prompt argument, and `agentPromptRidesLaunchCommand` — derived from the same injection
* table `buildAgentStartupPlan` branches on — is the one place that question is asked.
* argv has no readiness race, so it is offered wherever the agent's CLI takes a prompt argument
* (`agentPromptRidesLaunchCommand`). But that command is TYPED into the user's shell, and a long or
* multi-line typed line fails in ways argv itself does not, so whether the offer was taken is decided
* where the line is built (`startup-line-prompt-carry`) and reported back, never predicted here.
*/
import type {
@@ -39,11 +38,11 @@ export async function settleLaunchPromptDisposal(
const messageId = await deliverStructuredLaunchPrompt(execution, created.structured)
return messageId ? { outcome: 'journaled', messageId } : NOT_DELIVERED
}
// The startup command already carries an argv agent's prompt; there is nothing left to write.
// The surface reported that the startup command carried the prompt; there is nothing left to write.
if (created.promptRodeLaunchCommand) {
return HANDED_TO_TERMINAL
}
return deliverTerminalLaunchPrompt(execution, created.outcome.handle)
return deliverTerminalLaunchPrompt(execution, created.outcome.handle, { freshLaunch: true })
}
/**
@@ -82,13 +81,19 @@ async function deliverStructuredLaunchPrompt(
*/
export async function deliverTerminalLaunchPrompt(
execution: AgentLaunchExecution,
handle: string
handle: string,
{ freshLaunch }: { freshLaunch: boolean }
): Promise<AgentLaunchPromptDisposal> {
const { intent, surfaces } = execution
if (!intent.prompt || intent.prompt.delivery !== 'submit') {
return NOT_DELIVERED
}
const delivered = await surfaces.deliverTerminalPrompt?.({ handle, prompt: intent.prompt })
const delivered = await surfaces.deliverTerminalPrompt?.({
handle,
agent: intent.agent,
freshLaunch,
prompt: intent.prompt
})
return delivered ? HANDED_TO_TERMINAL : NOT_DELIVERED
}
@@ -98,8 +103,8 @@ function launchSubmitText(intent: AgentLaunchIntent): string | undefined {
}
/**
* The prompt a terminal's launch command should carry, which is an argv-mode agent's and only an
* argv-mode agent's.
* The prompt offered to a terminal's launch command, which is an argv-mode agent's and only an
* argv-mode agent's. An offer, not a decision: the surface reports whether the typed line took it.
*/
export function argvLaunchPrompt(intent: AgentLaunchIntent): string | undefined {
const text = launchSubmitText(intent)
@@ -25,8 +25,8 @@ export type AgentLaunchSurfaceFactory = {
worktreeId: string
agent: TuiAgent
options?: Readonly<Record<string, unknown>>
/** Set only for an agent whose CLI takes the prompt on argv, so the text is in the process's
* arguments at exec time rather than raced into its composer afterwards. */
/** Offered only for an agent whose CLI takes the prompt on argv. It rides the launch command
* only when the typed line can carry it; `promptRodeLaunchCommand` reports which happened. */
startupPrompt?: string
/** Replaces the settings default for this launch only; `null` means no arguments at all. */
agentArgs?: string | null
@@ -35,11 +35,15 @@ export type AgentLaunchSurfaceFactory = {
launchSource?: string
/** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */
paneKey?: string
/** How the tab first shows, as the caller would have opened it. */
viewMode?: 'terminal' | 'chat'
}): Promise<{
handle: string
/** The pane this create minted; a factory whose runtime reports none omits it, never invents. */
paneKey?: string
warning?: string
/** Reported by the surface that built the typed line, never predicted by the executor. */
promptRodeLaunchCommand?: boolean
}>
/**
* Commits the launch text as the session's first turn, answering with the transcript row's id.
@@ -56,12 +60,19 @@ export type AgentLaunchSurfaceFactory = {
/**
* Writes the launch text into a terminal agent's live PTY, answering whether it landed.
*
* The other half of `startupPrompt`, for the two cases argv cannot serve: a `stdin-after-start`
* agent, whose CLI takes no prompt argument, and a reused terminal, whose process was already
* running before this launch existed. `false` for every failure, on the same rule the structured
* The other half of `startupPrompt`, for the cases the launch command cannot serve: a
* `stdin-after-start` agent, whose CLI takes no prompt argument; a prompt the typed line cannot
* carry; and a reused terminal, whose process was already running before this launch existed. `false` for every failure, on the same rule the structured
* twin follows — a launch whose agent is running must not fail because its text did not land.
*/
deliverTerminalPrompt?(args: { handle: string; prompt: AgentLaunchPrompt }): Promise<boolean>
deliverTerminalPrompt?(args: {
handle: string
/** The launched agent, whose own readiness signal the write waits for. */
agent: TuiAgent
/** False for a reused terminal, which has no fresh launch readiness to wait for. */
freshLaunch: boolean
prompt: AgentLaunchPrompt
}): Promise<boolean>
}
/** `fence` is the lease the create was admitted at, carried so the launch prompt's send can fill its
@@ -95,8 +106,8 @@ export type AgentLaunchWorkspaceFactory = {
* wait-for-setup gate for free. A structured launch has no startup command to sequence and
* must await that gate explicitly instead. */
startupAgent: TuiAgent | undefined
/** Set only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first creation
* builds the startup command, so that is where an argv prompt belongs. */
/** Offered only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first
* creation builds the startup command, so that is where the typed line is measured. */
startupPrompt?: string
/** Inputs needed when this terminal is created as the worktree's startup surface. */
agentArgs?: string | null
@@ -112,5 +123,7 @@ export type AgentLaunchWorkspaceFactory = {
startupTerminalPaneKey?: string
/** Created, but incomplete — surfaced on the launch result rather than dropped. */
warning?: string
/** Reported by the create that built the startup command's typed line. */
promptRodeLaunchCommand?: boolean
}>
}
@@ -22,8 +22,9 @@ const LISTED: ReadonlyMap<string, { calls: number; decision: string }> = new Map
})
)
// planStartupWithPromptCandidate wraps buildAgentStartupPlan in shared/, which this scan does not reach.
const BUILDER_CALL =
/\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan)\s*\(/g
/\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planStartupWithPromptCandidate)\s*\(/g
const DECIDE =
'Decide attribution: a fresh agent the host builds must carry ' +
@@ -14,6 +14,8 @@ import {
type AdvertisedClient = {
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: readonly RuntimeCapability[]
trustedLocalCallerId?: string
pairedDeviceId?: string
}
const { handlers, advertised } = vi.hoisted(
@@ -59,6 +61,7 @@ vi.mock('../runtime/rpc/dispatcher', () => ({
const { registerRuntimeHandlers } = await import('./runtime')
const { supportsAgentLaunch } = await import('../runtime/rpc/methods/agent-launch')
const { agentLaunchOperationCallerKey } = await import('../runtime/rpc/methods/agent-launch-replay')
function rendererEvent() {
const mainFrame = {}
@@ -128,4 +131,20 @@ describe('desktop renderer reaching agent.launch on its own main process', () =>
)
expect(supportsAgentLaunch(streaming)).toBe(true)
})
// Why: the desktop has no paired device, so without its own host-set identity every
// replay-protected launch it sent was refused with agent_session_identity_required.
it('gives a replay-protected launch a caller identity of its own', () => {
invoke('runtime:call', {
method: 'agent.launchReplay',
params: { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } }
})
const client = onlyAdvertisedClient(advertised.unary)
expect(agentLaunchOperationCallerKey(client)).toBe('trusted-local:desktop-renderer')
// Negative control: the same client kind with no host-set identity is still refused.
expect(() =>
agentLaunchOperationCallerKey({ clientKind: client.clientKind, pairedDeviceId: undefined })
).toThrow('agent_session_identity_required')
})
})
+2
View File
@@ -78,6 +78,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
},
{
clientId: 'desktop-renderer',
trustedLocalCallerId: 'desktop-renderer',
clientKind: 'runtime',
connectionId: desktopSenders.connectionIdFor(event.sender),
clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES
@@ -120,6 +121,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
{
signal: controller.signal,
clientId: 'desktop-renderer',
trustedLocalCallerId: 'desktop-renderer',
clientKind: 'runtime',
connectionId,
clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-30T12:00:00.000Z",
"platform": "darwin",
"command": ["/bin/zsh", "-f", "-i"],
"cols": 80,
"rows": 24,
"note": "zsh 5.9, macOS arm64, clean environment, PS1='%# '. The prompt turns bracketed paste on (ESC[?2004h), `sleep 1` is typed and run, which turns it off (ESC[?2004l), and the next prompt turns it on again: the bytes a launch sees from the shell before its agent starts, and after an agent exits",
"exitCode": null
}
@@ -0,0 +1,2 @@
% ␍ ␍␍% [?2004hssleep 1[?2004l␍
% ␍ ␍␍% [?2004h
@@ -193,6 +193,23 @@ export function claimAgentSessionOperationInto(
return claimed.claim
}
/** Whether an admission left the right to run open, so the same transaction should claim it. */
export type ClaimAfterAdmission = (decision: AgentSessionOperationDecision) => boolean
/** Admission and, when `claimAfter` says so, the claim, in one transaction: the same swap as
* `claimAgentSessionOperationInto`, with one durable write instead of two. */
export function admitAndClaimAgentSessionOperationInto(
state: { operations: Map<string, AgentSessionOperationRow> },
args: AgentSessionOperationAdmission,
claimAfter: ClaimAfterAdmission
): { decision: AgentSessionOperationDecision; claim: AgentSessionOperationClaim | null } {
const decision = admitAgentSessionOperationInto(state, args)
return {
decision,
claim: claimAfter(decision) ? claimAgentSessionOperationInto(state, args) : null
}
}
export function settleAgentSessionOperationInto(
state: { operations: Map<string, AgentSessionOperationRow> },
args: { callerKey?: string; operationId: string; outcome: AgentSessionOperationOutcome }
@@ -18,6 +18,8 @@ import {
evaluateAgentSessionMutationOperation,
admitAgentSessionOperationInto,
claimAgentSessionOperationInto,
admitAndClaimAgentSessionOperationInto,
type ClaimAfterAdmission,
settleAgentSessionOperationInto,
type AgentSessionMutationOperationAdmission,
type AgentSessionOperationAdmission
@@ -284,6 +286,12 @@ export class AgentSessionRecordStore {
}): Promise<AgentSessionOperationClaim> =>
this.transact((draft) => claimAgentSessionOperationInto(draft, args))
/** Admission and, when `claimAfter` allows, the claim: one durable write before the effect. */
admitAndClaimOperation = (
args: AgentSessionOperationAdmission,
claimAfter: ClaimAfterAdmission
) => this.transact((draft) => admitAndClaimAgentSessionOperationInto(draft, args, claimAfter))
async recordOperationOutcome(args: {
callerKey?: string
operationId: string
@@ -52,16 +52,49 @@ function spawnedCommand(spawn: ReturnType<typeof vi.fn>): string {
}
describe('a terminal create that is handed a launch prompt', () => {
it('folds an argv agent’s prompt into the command it spawns', async () => {
it('folds an argv agent’s prompt into the command it spawns, and says it did', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch()
const onStartupPromptCarry = vi.fn()
await runtime.createTerminal('id:wt-1', {
startupAgent: 'claude',
startupPrompt: 'summarize the diff'
startupPrompt: 'summarize the diff',
onStartupPromptCarry
})
expect(spawnedCommand(spawn)).toContain('summarize the diff')
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ launchAgent: 'claude' }))
expect(onStartupPromptCarry).toHaveBeenCalledWith(true)
})
it('starts clean, and says so, when the typed line cannot carry a multi-line prompt', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch()
const onStartupPromptCarry = vi.fn()
await runtime.createTerminal('id:wt-1', {
startupAgent: 'claude',
startupPrompt: 'summarize the diff\nthen list the risks',
onStartupPromptCarry
})
// Typed into a shell, each newline would be Enter; the caller pastes it once the agent is up.
expect(spawnedCommand(spawn)).toContain('claude')
expect(spawnedCommand(spawn)).not.toContain('summarize')
expect(onStartupPromptCarry).toHaveBeenCalledWith(false)
})
it('starts Hermes clean instead of refusing when its env budget cannot hold the prompt', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch()
const onStartupPromptCarry = vi.fn()
await runtime.createTerminal('id:wt-1', {
startupAgent: 'hermes',
startupPrompt: 'x'.repeat(30_000),
onStartupPromptCarry
})
expect(spawn).toHaveBeenCalledTimes(1)
expect(onStartupPromptCarry).toHaveBeenCalledWith(false)
})
it('still builds a bare agent launch when no prompt is handed to it', async () => {
@@ -2,6 +2,7 @@
import { vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { TuiAgent } from '../../shared/tui-agent'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const TRANSCRIPT_PANE_TAB_ID = 'tab-1'
@@ -15,11 +16,22 @@ export type TranscriptPaneOptions = {
launchAgent?: TuiAgent
/** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */
connectionId?: string
/** The remote host of a `connectionId` pane is Windows. */
remoteWindowsHost?: boolean
/** Simulates a PTY controller whose foreground probe never settles. */
foregroundProbeHangs?: boolean
onForegroundProbe?: () => void
/** PTY grid the controller reports; the runtime's emulator otherwise defaults to 80x24. */
size?: { cols: number; rows: number }
/** What a fresh foreground scan finds, where it differs from the cached foreground read. */
confirmedForegroundProcess?: string | null
onForegroundScan?: () => void
/** What the host's process inspection answers; absent for a host without one. */
processInspection?: PtyProcessInspection
onProcessInspection?: () => void
/** What the host's shell-foreground check answers: the spawned shell holds the foreground. */
shellForegroundProven?: boolean
onShellForegroundProof?: () => void
}
export async function createTranscriptPane(
@@ -27,16 +39,21 @@ export async function createTranscriptPane(
runtimeDeps?: ConstructorParameters<typeof OrcaRuntimeService>[2]
): Promise<{ runtime: OrcaRuntimeService; handle: string }> {
const runtime = new OrcaRuntimeService(null, undefined, runtimeDeps)
// The runtime reads a remote pane's host OS from its worktree path.
const worktreeId = options.remoteWindowsHost
? 'repo-1::C:\\repo\\app'
: TRANSCRIPT_PANE_WORKTREE_ID
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
}
vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
id: TRANSCRIPT_PANE_WORKTREE_ID,
id: worktreeId,
path: '/repo/app',
connectionId: options.connectionId ?? null,
repo: null,
folderWorkspace: null
})
const processInspection = options.processInspection
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }),
write: () => true,
@@ -47,9 +64,33 @@ export async function createTranscriptPane(
return options.foregroundProbeHangs === true
? new Promise<string | null>(() => {})
: Promise.resolve(options.foregroundProcess)
}
},
...(options.confirmedForegroundProcess !== undefined
? {
confirmForegroundProcess: async () => {
options.onForegroundScan?.()
return options.confirmedForegroundProcess ?? null
}
}
: {}),
...(processInspection
? {
inspectProcess: async () => {
options.onProcessInspection?.()
return processInspection
}
}
: {}),
...(options.shellForegroundProven !== undefined
? {
confirmShellForeground: async () => {
options.onShellForegroundProof?.()
return options.shellForegroundProven === true
}
}
: {})
})
const terminal = await runtime.createTerminal(`id:${TRANSCRIPT_PANE_WORKTREE_ID}`, {
const terminal = await runtime.createTerminal(`id:${worktreeId}`, {
tabId: TRANSCRIPT_PANE_TAB_ID,
leafId: TRANSCRIPT_PANE_LEAF_ID,
title: 'Terminal'
@@ -59,7 +100,7 @@ export async function createTranscriptPane(
tabs: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId: TRANSCRIPT_PANE_WORKTREE_ID,
worktreeId,
title: 'Terminal',
activeLeafId: TRANSCRIPT_PANE_LEAF_ID,
layout: null
@@ -68,7 +109,7 @@ export async function createTranscriptPane(
leaves: [
{
tabId: TRANSCRIPT_PANE_TAB_ID,
worktreeId: TRANSCRIPT_PANE_WORKTREE_ID,
worktreeId,
leafId: TRANSCRIPT_PANE_LEAF_ID,
paneRuntimeId: 1,
ptyId: TRANSCRIPT_PANE_PTY_ID,
@@ -77,7 +118,7 @@ export async function createTranscriptPane(
]
})
if (options.launchAgent) {
runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, TRANSCRIPT_PANE_WORKTREE_ID, null, {
runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, worktreeId, options.connectionId ?? null, {
tabId: TRANSCRIPT_PANE_TAB_ID,
leafId: TRANSCRIPT_PANE_LEAF_ID,
incarnationId: 'inc-1',
@@ -0,0 +1,454 @@
/**
* A freshly launched Claude's first input, replayed from captured transcripts
* (`__fixtures__/claude-dialog-trust-workspace*.txt`).
*
* The launch pastes on the signal the desktop's own paste used: bracketed paste turned on, then a
* quiet render. Claude's first-launch trust dialog renders in that same mode, so the quiet window
* also settles over it, and only the screen check keeps the prompt out of the dialog.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
import {
waitForLaunchedAgentComposer,
waitForWorkerStartComposer
} from './launched-agent-composer-readiness'
import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
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') }
}))
/** The desktop paste's quiet window after bracketed paste, which the launch now shares. */
const QUIET_WINDOW_MS = 1_500
function readCapture(name: string): { data: string; size: { cols: number; rows: number } } {
const base = join(__dirname, '__fixtures__', name)
const meta: { cols: number; rows: number } = JSON.parse(readFileSync(`${base}.meta.json`, 'utf8'))
return { data: readFileSync(`${base}.txt`, 'utf8'), size: { cols: meta.cols, rows: meta.rows } }
}
/** Starts the launch wait on an empty pane, then streams the capture in, as a live launch does. */
async function launchAndStream(
name: string,
timeoutMs: number,
pane: Partial<Parameters<typeof createTranscriptPane>[0]> = {}
) {
const { data, size } = readCapture(name)
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'claude',
launchAgent: 'claude',
size,
data: '',
...pane
})
// Pane creation awaits real timers; the wait and its quiet window use the virtual clock.
vi.useFakeTimers()
const ready = waitForLaunchedAgentComposer(runtime, handle, 'claude', timeoutMs)
const settled = vi.fn()
ready.then(settled, settled)
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, data, Date.now())
return { ready, settled }
}
describe('launch readiness for a freshly launched Claude', () => {
afterEach(() => {
vi.useRealTimers()
})
it('claude-dialog-trust-workspace-answered: reads the composer after the answered dialog as ready', async () => {
const { data } = readCapture('claude-dialog-trust-workspace-answered')
// Presence precondition: the capture turns bracketed paste on and ends on the idle composer.
expect(data).toContain('\x1b[?2004h')
const { ready, settled } = await launchAndStream(
'claude-dialog-trust-workspace-answered',
60_000
)
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS + 100)
expect(settled).toHaveBeenCalled()
await expect(ready).resolves.toMatchObject({ satisfied: true })
})
it('claude-dialog-trust-workspace-answered: over SSH, settles on the quiet window, not the 8 s fallback', async () => {
const { ready, settled } = await launchAndStream(
'claude-dialog-trust-workspace-answered',
60_000,
// The relay offers no scan or shell check; either claiming a shell would refuse this signal.
{ connectionId: 'ssh-1', confirmedForegroundProcess: 'zsh', shellForegroundProven: true }
)
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS + 300)
expect(settled).toHaveBeenCalled()
await expect(ready).resolves.toMatchObject({ satisfied: true })
})
it('claude-dialog-trust-workspace: never reads the trust dialog as the composer', async () => {
const { ready, settled } = await launchAndStream('claude-dialog-trust-workspace', 60_000)
// Reported inside the desktop paste's budget: the quiet window settles over the dialog, the
// screen check refuses it, and the idle wait names it.
await vi.advanceTimersByTimeAsync(4_000)
expect(settled).toHaveBeenCalled()
await expect(ready).resolves.toMatchObject({
satisfied: false,
blockedReason: 'agent-trust-workspace'
})
})
})
describe('a fresh orchestration worker start for Claude', () => {
afterEach(() => {
vi.useRealTimers()
})
// Main's worker start pasted on this cue; the launch's quiet window put the brief 1.6 s later.
it('settles on Claude’s ready title, before the launch paste’s quiet window', async () => {
const { data, size } = readCapture('claude-dialog-trust-workspace-answered')
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'claude',
launchAgent: 'claude',
size,
data: ''
})
vi.useFakeTimers()
const ready = waitForWorkerStartComposer(runtime, handle, 'claude', 60_000)
const settled = vi.fn()
ready.then(settled, settled)
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, data, Date.now())
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS / 2)
expect(settled).toHaveBeenCalled()
await expect(ready).resolves.toMatchObject({ satisfied: true })
})
})
describe('whether a shell is proven in front of a launched agent’s terminal', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
const setPlatform = (value: NodeJS.Platform): void => {
Object.defineProperty(process, 'platform', { configurable: true, value })
}
afterEach(() => {
Object.defineProperty(process, 'platform', originalPlatform)
})
describe('macOS and Linux: a fresh foreground read', () => {
beforeEach(() => setPlatform('darwin'))
it.each([
// For its first 5 s the daemon's cached read names the launch agent while zsh is in front.
['claude', 'zsh', true],
['claude', 'claude', false],
['zsh', 'zsh', true],
['-zsh', '-zsh', true],
['bash', 'bash', true],
[null, 'zsh', true],
[null, null, false]
])('cached %s, scan %s: %s', async (foregroundProcess, scanned, shellInFront) => {
const scan = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess,
confirmedForegroundProcess: scanned,
onForegroundScan: scan,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
shellInFront
)
expect(scan).toHaveBeenCalledOnce()
})
// The terminal daemon answers the shell-foreground check from its recovery state, which an
// agent that exits without leaving a full-screen mode up never sets.
it('an agent that exited: the scan names zsh while the daemon’s shell check says no', async () => {
const proof = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'copilot',
foregroundProcess: 'copilot',
confirmedForegroundProcess: 'zsh',
shellForegroundProven: false,
onShellForegroundProof: proof,
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
true
)
expect(proof).not.toHaveBeenCalled()
})
it('takes the cached read on a controller without a scan', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'zsh',
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
true
)
})
})
describe('Windows: only the shell-foreground check', () => {
beforeEach(() => setPlatform('win32'))
// The scan names the pane's shell for an agent it cannot recognize (an npm agent as
// `node.exe`); only the shell alone in the pane's job proves one.
it.each([
['powershell.exe', false, false],
['powershell.exe', true, true],
['claude', true, true],
['claude', false, false]
])('cached %s, shell check %s: %s', async (foregroundProcess, proven, shellInFront) => {
const scan = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess,
confirmedForegroundProcess: 'powershell.exe',
onForegroundScan: scan,
shellForegroundProven: proven,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
shellInFront
)
expect(scan).not.toHaveBeenCalled()
})
it('proves nothing on a controller without a shell check', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'powershell.exe',
confirmedForegroundProcess: 'powershell.exe',
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
false
)
})
})
// The stubs always disagree with the relay's name, so asking either would flip the answer.
it.each([
['claude', false],
['bash', true]
])(
'SSH: takes the relay’s own read %s (shell %s), without a scan or check',
async (relayRead, shell) => {
const scan = vi.fn()
const proof = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: relayRead,
confirmedForegroundProcess: shell ? 'claude' : 'zsh',
onForegroundScan: scan,
shellForegroundProven: !shell,
onShellForegroundProof: proof,
connectionId: 'ssh-1',
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
shell
)
expect(scan).not.toHaveBeenCalled()
expect(proof).not.toHaveBeenCalled()
}
)
describe('macOS and Linux: by the shell’s identity, from the host’s process inspection', () => {
beforeEach(() => setPlatform('darwin'))
// Captured with `ps -axo pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command=` on a pty running
// `zsh -f -i` (macOS 26): first while it ran `bash -c 'sleep 4; :'`, then back at its prompt.
const zsh = (tpgid: number, stat: string): ProcessTableRow => ({
pid: 20894,
ppid: 20891,
pgid: 20894,
tpgid,
stat,
tty: 'ttys000',
startTime: 'Wed Sep 30 09:18:50 2026',
command: '/bin/zsh -f -i'
})
const wrapperRunning: ProcessTableRow[] = [
zsh(20996, 'Ss'),
{
pid: 20996,
ppid: 20894,
pgid: 20996,
tpgid: 20996,
stat: 'S+',
tty: 'ttys000',
startTime: 'Wed Sep 30 09:18:52 2026',
command: 'bash -c sleep 4; :'
},
{
pid: 20997,
ppid: 20996,
pgid: 20996,
tpgid: 20996,
stat: 'S+',
tty: 'ttys000',
startTime: 'Wed Sep 30 09:18:52 2026',
command: 'sleep 4'
}
]
const zshAtPrompt: ProcessTableRow[] = [zsh(20894, 'Ss+')]
const inspect = (rows: ProcessTableRow[]) => ({
foregroundProcess: 'zsh',
hasChildProcesses: rows.length > 1,
foregroundProcessEvidence: resolveRemoteForegroundEvidence(
{ rootPid: 20894, fallbackProcess: 'zsh' },
{
ptyId: TRANSCRIPT_PANE_PTY_ID,
ptyIncarnationId: 'inc-1',
authorityGeneration: 'gen-1',
observationEpoch: 1,
capturedAgeMs: 0,
platform: 'darwin'
},
rows
)
})
it.each([
// A wrapper script's bash runs as its own job: named like a shell, but not the pane's shell.
['a bash wrapper under zsh is not the shell', wrapperRunning, 'bash', false, false],
['zsh back at its prompt is the shell', zshAtPrompt, 'zsh', true, true]
] as const)('%s', async (_label, rows, foregroundName, shell, scanned) => {
const inspection = inspect([...rows])
// Presence precondition: the captured rows are a live observation, not an unreadable one.
expect(inspection.foregroundProcessEvidence).toMatchObject({ verdict: 'live' })
const scan = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: foregroundName,
processInspection: inspection,
confirmedForegroundProcess: foregroundName,
onForegroundScan: scan,
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
shell
)
expect(scan).toHaveBeenCalledTimes(scanned ? 1 : 0)
})
it('SSH: takes the relay’s inspection the same way', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'bash',
processInspection: inspect([...wrapperRunning]),
connectionId: 'ssh-1',
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
false
)
})
it('an agent in the shell’s own group (a shell without job control) is not the shell', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Copilot',
// node-pty names the group's leader, which is the shell itself here.
foregroundProcess: 'zsh',
processInspection: inspect([...zshAtPrompt]),
confirmedForegroundProcess: 'node',
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
false
)
})
it('an inspection that cannot observe proves nothing, and falls back to no name', async () => {
const scan = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'zsh',
processInspection: inspect([]),
confirmedForegroundProcess: 'zsh',
onForegroundScan: scan,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
false
)
expect(scan).not.toHaveBeenCalled()
})
})
// Why: a Windows relay names the pane's shell for an agent its scan cannot recognize (node.exe).
it('SSH to a Windows host: the relay’s shell name proves nothing', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Copilot',
foregroundProcess: 'powershell.exe',
connectionId: 'ssh-1',
remoteWindowsHost: true,
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
false
)
})
// Why no read: a native Claude names itself by its version, which the cached read gives at once.
it.each([
['darwin', '2.1.285'],
['darwin', 'node'],
['win32', 'node']
] as const)(
'%s: takes the non-shell name %s, other than the agent’s own, at once',
async (platform, foregroundProcess) => {
setPlatform(platform)
const scan = vi.fn()
const proof = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess,
confirmedForegroundProcess: 'zsh',
onForegroundScan: scan,
shellForegroundProven: true,
onShellForegroundProof: proof,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
false
)
expect(scan).not.toHaveBeenCalled()
expect(proof).not.toHaveBeenCalled()
}
)
})
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
import { readRuntimeFixture } from './agent-transcript-replay-test-harness'
import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness'
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') }
}))
// A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex
// release that the launch wait cannot read ready reports "prompt wasn't sent".
async function launchedCodexPane(data: string) {
return createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'codex',
launchAgent: 'codex',
data,
size: { cols: 120, rows: 40 }
})
}
// Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every
// 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed
// whole it holds a starred composer still, which the live stream never does.
function greetingBeforeStarfield(): string {
const data = readRuntimeFixture('codex-0158-fresh-home-greeting')
const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/)
return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar))
}
describe('launch readiness for a freshly launched Codex', () => {
it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => {
const data = greetingBeforeStarfield()
// Presence precondition: the cut keeps the live status row and drops every star.
expect(data).toContain('GPT-6-Astra default')
expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/)
const { runtime, handle } = await launchedCodexPane(data)
await expect(
waitForLaunchedAgentComposer(runtime, handle, 'codex', 8_000)
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
}, 15_000)
it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => {
const { runtime, handle } = await launchedCodexPane(
readRuntimeFixture('codex-0157-plain-ready')
)
await expect(
waitForLaunchedAgentComposer(runtime, handle, 'codex', 8_000)
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
}, 15_000)
it.each([
['codex-0158-update-available-dialog', 'agent-update-prompt'],
['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'],
['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'],
['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt']
])(
'%s: stops at the dialog as %s instead of pasting into it',
async (fixture, reason) => {
const { runtime, handle } = await launchedCodexPane(readRuntimeFixture(fixture))
await expect(
waitForLaunchedAgentComposer(runtime, handle, 'codex', 2_500)
).resolves.toMatchObject({ satisfied: false, blockedReason: reason })
},
15_000
)
// Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the
// composer signal, so only the screen check keeps it from pasting; it hands the dialog to
// `tui-idle` to report, well inside the desktop paste's budget.
it.each([
['codex-0157-update-available-dialog', 'agent-update-prompt'],
['codex-0158-update-available-dialog', 'agent-update-prompt'],
['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'],
['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'],
['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt']
])(
'%s streamed in after the launch: reports the dialog as %s, never as the composer',
async (fixture, reason) => {
const data = readRuntimeFixture(fixture)
// Presence precondition: the dialog draws Codex's composer glyph after bracketed paste.
expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›')
const { runtime, handle } = await launchedCodexPane('')
const ready = waitForLaunchedAgentComposer(runtime, handle, 'codex', 2_500)
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, data, Date.now())
await expect(ready).resolves.toMatchObject({ satisfied: false, blockedReason: reason })
},
15_000
)
// Why: 0.157 discards input typed behind its provisional `model: loading` screen.
it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => {
const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install')
const install = full.indexOf('Installing daemon')
// Presence precondition: the cut keeps the provisional header and stops before the live chat.
expect(install).toBeGreaterThan(0)
const provisional = full.slice(0, full.indexOf('\n', install) + 1)
expect(provisional).toMatch(/model:.*loading/)
const { runtime, handle } = await launchedCodexPane(provisional)
await expect(waitForLaunchedAgentComposer(runtime, handle, 'codex', 5_000)).rejects.toThrow(
/timeout/
)
}, 15_000)
})
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts'
import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace'
import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-startup-pty-trace'
import { GROK_INLINE_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-inline-startup-pty-trace'
import {
waitForLaunchedAgentComposer,
type LaunchedAgentReadinessRuntime
} from './launched-agent-composer-readiness'
import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness'
const READY: RuntimeTerminalWait = {
handle: 'term-1',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
}
/** The runtime's composer wait over a replayed PTY stream, with its defaults. */
function replayRuntime() {
let listener = (_data: string): void => {}
const waitForFreshWorkerComposer = vi.fn(
async (
handle: string,
agent: TuiAgent,
timeoutMs: number,
{ requireComposerMarker = true }: { requireComposerMarker?: boolean } = {}
): Promise<RuntimeTerminalWait> => {
const ptyId = await waitForWorktreeStartupDraft(
{
getPtyId: () => 'pty-1',
getForegroundProcess: async () => agent,
subscribeToData: (_ptyId, onData) => {
listener = onData
return () => {
listener = () => {}
}
},
readRecentOutput: () => undefined,
write: vi.fn()
},
handle,
agent,
{ timeoutMs, requireComposerMarker }
)
if (!ptyId) {
throw new Error('timeout')
}
return READY
}
)
/** The `tui-idle` fallback: pending until a test settles it. */
let settleIdle = (_wait: RuntimeTerminalWait): void => {}
const waitForTerminal = vi.fn(
(): Promise<RuntimeTerminalWait> =>
new Promise((resolve) => {
settleIdle = resolve
})
)
const runtime: LaunchedAgentReadinessRuntime = {
waitForTerminal,
waitForFreshWorkerComposer
}
const play = async (trace: GrokStartupTraceChunk[]): Promise<void> => {
let now = 0
for (const chunk of trace) {
await vi.advanceTimersByTimeAsync(chunk.t - now)
now = chunk.t
listener(chunk.data ?? 'x'.repeat(chunk.bytes ?? 0))
}
}
return {
runtime,
waitForTerminal,
play,
feed: (data: string) => listener(data),
settleIdle: (wait: RuntimeTerminalWait) => settleIdle(wait)
}
}
describe('launched grok composer readiness', () => {
afterEach(() => vi.useRealTimers())
it('opens on the composer frame in the default full-screen mode', async () => {
vi.useFakeTimers()
const h = replayRuntime()
const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'grok', 60_000)
await h.play(GROK_STARTUP_PTY_TRACE)
await expect(ready).resolves.toEqual(READY)
})
it('still opens in inline mode, which never switches to the alternate screen', async () => {
// `grok --no-alt-screen` / `screen_mode = "minimal"` paints its `❯` without the anchor the
// marker needs, so the quiet window after bracketed paste is its only readiness.
vi.useFakeTimers()
const h = replayRuntime()
const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'grok', 60_000)
const settled = vi.fn()
void ready.then(settled, settled)
await h.play(GROK_INLINE_STARTUP_PTY_TRACE)
await vi.advanceTimersByTimeAsync(5_000)
expect(settled).toHaveBeenCalledWith(READY)
})
it('keeps ZCode on its composer marker alone', async () => {
vi.useFakeTimers()
const h = replayRuntime()
const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'zcode', 1_000)
void ready.catch(() => {})
expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith('term-1', 'zcode', 1_000, {
requireComposerMarker: true
})
await vi.advanceTimersByTimeAsync(1_000)
await expect(ready).rejects.toThrow('timeout')
})
})
describe('launched composer readiness for agents the idle evidence can also read', () => {
afterEach(() => vi.useRealTimers())
it('pastes on the quiet window after bracketed paste, as the desktop did, without the idle evidence', async () => {
vi.useFakeTimers()
const h = replayRuntime()
const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'claude', 60_000)
const settled = vi.fn()
void ready.then(settled, settled)
h.feed('\x1b[?2004h\x1b[?25l welcome to claude code \x1b[?25h')
await vi.advanceTimersByTimeAsync(1_400)
expect(settled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(200)
expect(settled).toHaveBeenCalledWith(READY)
expect(h.waitForTerminal).not.toHaveBeenCalled()
})
it('checks the idle evidence only once the desktop paste’s budget ran out, where it pasted blind', async () => {
vi.useFakeTimers()
const h = replayRuntime()
const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'claude', 60_000)
// Output, but bracketed paste never turns on, so the composer signal cannot fire.
h.feed('\x1b[?25l welcome to claude code \x1b[?25h')
await vi.advanceTimersByTimeAsync(7_900)
expect(h.waitForTerminal).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(200)
expect(h.waitForTerminal).toHaveBeenCalledWith('term-1', {
condition: 'tui-idle',
timeoutMs: 52_000,
launchReadiness: true
})
h.settleIdle(READY)
await expect(ready).resolves.toEqual(READY)
})
})
@@ -0,0 +1,130 @@
/**
* The one answer to "has the agent Orca just launched opened its composer?", shared by every host
* path that writes a first input into a fresh agent: `agent.launch`'s terminal prompt and, through
* `waitForWorkerStartComposer`, an orchestration worker's first dispatch.
*
* The signal is the one the desktop's own paste used: bracketed paste turned on (DECSET 2004) plus
* the agent's `draftPasteReadySignal` (its composer marker, or a quiet render after 2004), read by
* the shared `draft-paste-ready-scanner`, within the same per-agent budget. That signal cannot tell
* a composer from a startup dialog drawn in the same mode, so it counts only while the pane shows no
* startup dialog and no Codex provisional header (`readFreshComposerHold`). Unlike the desktop's
* paste it reads only output after the shell's last `?2004l`, and drops a signal while a shell is
* proven in front (`isLaunchShellInFront`).
*
* Where the desktop pasted blind once its budget ran out, the host falls back to the `tui-idle`
* evidence ranking (idle titles, known ready screens), which also reports a dialog left up. A few
* agents show readiness only in their composer, which that ranking cannot read: ZCode paints no
* title and repaints its banner forever, DSH's idle hook fires only after a turn, and Grok's only
* title is its bare name. They wait for their marker alone.
*/
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts'
import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout'
import type { OrcaRuntimeService } from './orca-runtime'
import { isCodexProvisionalStartupText } from './codex-terminal-readiness'
import { detectTerminalWaitBlockedReason } from './terminal-wait-detection'
/**
* Agents whose launch readiness is a composer marker pinned by a captured transcript
* (`zcode-readiness-transcript.test.ts`, `dsh-readiness-transcript.test.ts`,
* `draft-paste-ready-scanner-grok-trace-replay.test.ts`). Grok is here because its only other
* evidence is its bare name, which a shell auto-title also writes, and its screen never quiets.
*/
const COMPOSER_MARKER_READINESS_AGENTS: ReadonlySet<TuiAgent> = new Set(['zcode', 'dsh', 'grok'])
/**
* Composer-marker agents that can also render inline, where the marker's alternate-screen anchor
* never arrives (`grok-inline-startup-pty-trace.ts`). The quiet window after bracketed paste stays
* armed for them as the floor, as the desktop's own paste and worktree.create's draft paste use it.
*/
const INLINE_RENDERING_COMPOSER_AGENTS: ReadonlySet<TuiAgent> = new Set(['grok'])
export type LaunchedAgentReadinessRuntime = Pick<
OrcaRuntimeService,
'waitForTerminal' | 'waitForFreshWorkerComposer'
>
/**
* What keeps a ready signal from counting: a startup dialog in the pane's text or on its screen, or
* Codex 0.157's provisional `model: loading` header, which discards input typed behind it.
*/
export function readFreshComposerHold(
waitText: string,
screenLines: readonly string[] | null
): 'dialog' | 'starting' | null {
if (
detectTerminalWaitBlockedReason(waitText) !== null ||
(screenLines !== null && detectTerminalWaitBlockedReason(screenLines.join('\n')) !== null)
) {
return 'dialog'
}
return isCodexProvisionalStartupText(waitText.toLowerCase()) ? 'starting' : null
}
/**
* A fresh orchestration worker's first dispatch. Marker agents wait for their marker, as a launch
* does; every other agent takes main's cue for this path, `tui-idle`, which settles on the agent's
* own ready title instead of a quiet window after it. That dispatch waits for the render to settle
* before Enter, so it never needed the desktop paste's later cue.
*/
export function waitForWorkerStartComposer(
runtime: LaunchedAgentReadinessRuntime,
handle: string,
agent: TuiAgent,
timeoutMs: number
): Promise<RuntimeTerminalWait> {
if (COMPOSER_MARKER_READINESS_AGENTS.has(agent)) {
return waitForComposerMarker(runtime, handle, agent, timeoutMs)
}
return runtime.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs,
launchReadiness: true
})
}
function waitForComposerMarker(
runtime: LaunchedAgentReadinessRuntime,
handle: string,
agent: TuiAgent,
timeoutMs: number
): Promise<RuntimeTerminalWait> {
return runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs, {
requireComposerMarker: !INLINE_RENDERING_COMPOSER_AGENTS.has(agent)
})
}
/**
* The composer signal's wait, then — if it did not settle within the desktop paste's budget, or a
* startup dialog is up — the `tui-idle` wait for what is left of `timeoutMs`, whose result says
* ready, blocked by a dialog, or not ready. Throws when that runs out too.
*/
export async function waitForLaunchedAgentComposer(
runtime: LaunchedAgentReadinessRuntime,
handle: string,
agent: TuiAgent,
timeoutMs: number
): Promise<RuntimeTerminalWait> {
if (COMPOSER_MARKER_READINESS_AGENTS.has(agent)) {
return waitForComposerMarker(runtime, handle, agent, timeoutMs)
}
const startedAt = Date.now()
try {
return await runtime.waitForFreshWorkerComposer(
handle,
agent,
Math.min(timeoutMs, resolveDraftPasteReadyTimeoutMs(agent)),
{ requireComposerMarker: false, stopOnDialog: true }
)
} catch {
// Out of budget, a dialog up, or a pane it could not read: the idle wait answers each, and
// throws for a handle that is gone.
}
// Checked, where the desktop pasted blind: an agent that shows no readiness keeps its text.
return runtime.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs: Math.max(1, timeoutMs - (Date.now() - startedAt)),
launchReadiness: true
})
}
@@ -0,0 +1,92 @@
import {
isExpectedAgentProcess,
recognizeAgentProcess
} from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
import type { TuiAgent } from '../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
/** A login shell is reported as `-zsh`. */
function isLaunchShell(processName: string): boolean {
return isShellProcess(processName.replace(/^-/, ''))
}
/**
* Whether the pane's own shell, rather than the agent a launch started, is proven to be in the
* terminal's foreground: the launch line has not run yet, or the agent exited. A read that fails or
* cannot tell proves nothing, so it never delays a launch.
*
* On macOS and Linux identity decides first: the host's process inspection reports the terminal's
* foreground process group, and the pane's shell can be in front only when that group is the
* shell's own. A wrapper script's bash, or a conda hook, runs as its own job, so it is not the
* pane's shell even though it is named like one. A fresh read must then also name a shell.
*
* Windows has no foreground process group, and its scan names the pane's shell for any agent it
* cannot recognize (an npm agent running as `node.exe`), so it takes only the host's
* shell-foreground check (the shell alone in the pane's job). A daemon pane answers that from its
* recovery state, which a plain exit leaves unset, and an SSH pane has none, so there it proves
* nothing.
*
* Why no check for a non-shell name other than the agent's own: for its first 5 s the daemon's
* cached read names the launch agent whenever a shell is in front (measured on a zsh pane: cached
* `copilot`, scan `zsh`), so any other non-shell name it gives is the process itself. macOS
* reports the native Claude by its version (`2.1.258`), and a runtime such as node can front an
* agent.
*/
export async function isShellInFrontOfLaunchedAgent(
controller: Pick<
RuntimePtyController,
| 'getForegroundProcess'
| 'inspectProcess'
| 'confirmForegroundProcess'
| 'confirmShellForeground'
> | null,
host: { remote: boolean; windows: boolean },
ptyId: string,
agent: TuiAgent
): Promise<boolean> {
if (!controller) {
return false
}
try {
const cached = await controller.getForegroundProcess(ptyId)
if (cached && !isLaunchShell(cached) && recognizeAgentProcess(cached)?.agent !== agent) {
return false
}
if (host.windows) {
// An SSH pane has no such check, so on a Windows relay nothing proves a shell.
return (await controller.confirmShellForeground?.(ptyId)) ?? false
}
const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence
if (
evidence &&
!(
evidence.verdict === 'live' &&
evidence.fence.platform === 'posix' &&
evidence.fence.foregroundPgid === evidence.fence.shellPid
)
) {
return false
}
// Why the name too: a shell without job control runs its commands in its own group.
// A host that predates the evidence (an older daemon or relay, or a pane outside the daemon)
// has only the name, which reads a wrapper script's bash as the shell. A relay's cached name is
// read live, and it has no scan.
const foreground =
!host.remote && controller.confirmForegroundProcess
? await controller.confirmForegroundProcess(ptyId)
: cached
return isShellName(foreground, agent)
} catch {
return false
}
}
function isShellName(processName: string | null, agent: TuiAgent): boolean {
return (
!!processName &&
isLaunchShell(processName) &&
!isExpectedAgentProcess(processName, TUI_AGENT_CONFIG[agent].expectedProcess)
)
}
@@ -0,0 +1,249 @@
/**
* A launched agent's ready signal must come from the agent, not from the shell that ran it.
*
* Replayed from captures: zsh turns bracketed paste on at its prompt and off when it runs the
* typed command, then on again at its next prompt (`zsh-prompt-runs-command.txt`); Claude turns it
* on when it draws (`claude-dialog-trust-workspace-answered.txt`). Read as the agent's, the shell's
* prompt settled the quiet window before Claude had drawn anything, and after an agent that exited.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TuiAgent } from '../../shared/tui-agent'
import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness'
const QUIET_WINDOW_MS = 1_500
const SHELL_HANDOFF = '\x1b[?2004l'
function readFixture(name: string): string {
return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
}
/** The shell's prompt and the launch line it runs, up to where it hands the terminal over. */
function shellRunsLaunchLine(): string {
const zsh = readFixture('zsh-prompt-runs-command')
const handoff = zsh.indexOf(SHELL_HANDOFF)
return zsh.slice(0, zsh.indexOf('\n', handoff) + 1)
}
/** The prompt the shell draws again once the command it ran has exited. */
function shellPromptAfterCommandExits(): string {
const zsh = readFixture('zsh-prompt-runs-command')
return zsh.slice(zsh.indexOf('\n', zsh.indexOf(SHELL_HANDOFF)) + 1)
}
function launchedPane(
options: {
agent?: TuiAgent
/** Whether the check proves a shell for this foreground name; default: only `zsh` does. */
provesShell?: (name: string) => boolean
checkDelayMs?: number
} = {}
) {
let listener = (_data: string): void => {}
let foreground = 'zsh'
let reads = 0
const host = {
getPtyId: () => 'pty-1',
getForegroundProcess: async () => foreground,
subscribeToData: (_ptyId: string, onData: (data: string) => void) => {
listener = onData
return () => {
listener = () => {}
}
},
readRecentOutput: () => undefined,
write: vi.fn()
}
const provesShell = options.provesShell ?? ((name: string) => name === 'zsh')
const ready = waitForWorktreeStartupDraft(host, 'term-1', options.agent ?? 'claude', {
timeoutMs: 8_000,
isShellInFront: async () => {
reads += 1
const name = foreground
if (options.checkDelayMs) {
await new Promise((resolve) => setTimeout(resolve, options.checkDelayMs))
}
return provesShell(name)
}
})
const settled = vi.fn()
void ready.then(settled)
return {
settled,
get reads() {
return reads
},
emit: (data: string) => listener(data),
setForeground: (name: string) => {
foreground = name
}
}
}
describe('a launched agent’s ready signal, after the shell that ran it', () => {
afterEach(() => vi.useRealTimers())
it('waits for the agent’s own bracketed paste when the agent starts well after the shell’s prompt', async () => {
vi.useFakeTimers()
const pane = launchedPane()
const launchLine = shellRunsLaunchLine()
// Presence precondition: the shell enabled bracketed paste before it handed the terminal over.
expect(launchLine).toContain('\x1b[?2004h')
pane.emit(launchLine)
// The agent's process takes the terminal, then draws nothing for 3 s while it starts.
pane.setForeground('claude')
await vi.advanceTimersByTimeAsync(3_000)
expect(pane.settled).not.toHaveBeenCalled()
pane.emit(readFixture('claude-dialog-trust-workspace-answered'))
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS - 100)
expect(pane.settled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(200)
expect(pane.settled).toHaveBeenCalledWith('pty-1')
})
it('never reads the shell’s next prompt as the agent’s composer after the agent exits', async () => {
vi.useFakeTimers()
const pane = launchedPane()
pane.emit(shellRunsLaunchLine())
// The agent exits at startup: its shell is back in the foreground and draws its prompt.
pane.emit('claude: failed to start\r\n')
pane.emit(shellPromptAfterCommandExits())
await vi.advanceTimersByTimeAsync(8_000)
expect(pane.settled).toHaveBeenCalledWith(null)
// The refused signal is dropped, not retried until the budget ends.
expect(pane.reads).toBe(1)
})
// Why: a quiet agent never signals again, so only a proven shell may drop its signal. Requiring
// proof of the agent left Claude idle until the 8 s fallback wherever the read could not answer.
it('settles on the signal when the check cannot prove a shell, with one read', async () => {
vi.useFakeTimers()
const pane = launchedPane({ provesShell: () => false })
pane.emit(shellRunsLaunchLine())
pane.setForeground('2.1.285')
pane.emit(readFixture('claude-dialog-trust-workspace-answered'))
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS)
expect(pane.settled).toHaveBeenCalledWith('pty-1')
expect(pane.reads).toBe(1)
})
it('settles a Claude that is in front the moment its signal fires, with one check', async () => {
vi.useFakeTimers()
const pane = launchedPane()
pane.emit(shellRunsLaunchLine())
pane.setForeground('2.1.285')
pane.emit(readFixture('claude-dialog-trust-workspace-answered'))
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS)
expect(pane.settled).toHaveBeenCalledWith('pty-1')
expect(pane.reads).toBe(1)
})
it('reads nothing before the signal when the shell never enables bracketed paste', async () => {
vi.useFakeTimers()
const pane = launchedPane()
pane.emit('$ claude\r\n')
pane.setForeground('2.1.285')
await vi.advanceTimersByTimeAsync(300)
expect(pane.reads).toBe(0)
pane.emit(readFixture('claude-dialog-trust-workspace-answered'))
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS)
expect(pane.settled).toHaveBeenCalledWith('pty-1')
expect(pane.reads).toBe(1)
})
// A startup file's subprocess (a conda or pyenv hook) in front before the prompt proves nothing
// about the agent, so the shell's prompt after it must not settle the wait.
it('does not settle while Claude is still silent after a startup-file subprocess ran', async () => {
vi.useFakeTimers()
const pane = launchedPane()
pane.setForeground('python3.11')
await vi.advanceTimersByTimeAsync(300)
pane.setForeground('zsh')
pane.emit(shellRunsLaunchLine())
pane.setForeground('2.1.285')
await vi.advanceTimersByTimeAsync(3_000)
expect(pane.settled).not.toHaveBeenCalled()
})
it('counts only what follows the last hand-off when the shell draws two prompts first', async () => {
vi.useFakeTimers()
const pane = launchedPane({ provesShell: () => false })
// A first command runs and the prompt comes back, as separate chunks, before the launch line.
pane.emit(shellRunsLaunchLine())
pane.emit(shellPromptAfterCommandExits())
await vi.advanceTimersByTimeAsync(100)
pane.emit(`claude${SHELL_HANDOFF}\r\r\n`)
pane.setForeground('2.1.285')
await vi.advanceTimersByTimeAsync(3_000)
expect(pane.settled).not.toHaveBeenCalled()
pane.emit(readFixture('claude-dialog-trust-workspace-answered'))
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS)
expect(pane.settled).toHaveBeenCalledWith('pty-1')
})
it('sees a hand-off split across two chunks', async () => {
vi.useFakeTimers()
const pane = launchedPane({ provesShell: () => false })
const launchLine = shellRunsLaunchLine()
const cut = launchLine.indexOf(SHELL_HANDOFF) + 4
pane.emit(launchLine.slice(0, cut))
pane.emit(launchLine.slice(cut))
pane.setForeground('2.1.285')
await vi.advanceTimersByTimeAsync(3_000)
expect(pane.settled).not.toHaveBeenCalled()
pane.emit(readFixture('claude-dialog-trust-workspace-answered'))
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS)
expect(pane.settled).toHaveBeenCalledWith('pty-1')
})
it('never settles a shell signal whose check was still running when the shell handed over', async () => {
vi.useFakeTimers()
// The check answers after the launch line ran, when the agent is in front.
const pane = launchedPane({ provesShell: () => false, checkDelayMs: 400 })
const zsh = readFixture('zsh-prompt-runs-command')
const handoff = zsh.indexOf(SHELL_HANDOFF)
// The prompt with its launch line typed, then quiet long enough to fire on the shell's 2004.
pane.emit(zsh.slice(0, handoff))
await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS + 100)
expect(pane.reads).toBe(1)
pane.emit(zsh.slice(handoff, zsh.indexOf('\n', handoff) + 1))
pane.setForeground('2.1.285')
await vi.advanceTimersByTimeAsync(3_000)
expect(pane.settled).not.toHaveBeenCalled()
})
it('settles Codex 0.157 on its marker although it turns bracketed paste off and on as it starts', async () => {
vi.useFakeTimers()
const codex = readFixture('codex-0157-plain-ready')
// Presence precondition: the capture hands bracketed paste off mid-startup.
expect(codex).toContain(SHELL_HANDOFF)
const pane = launchedPane({ agent: 'codex' })
pane.emit(shellRunsLaunchLine())
pane.setForeground('codex')
pane.emit(codex)
await vi.advanceTimersByTimeAsync(50)
expect(pane.settled).toHaveBeenCalledWith('pty-1')
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import {
createLaunchedAgentWriteGuard,
type LaunchedAgentWriteGuardRuntime
} from './launched-agent-write-guard'
function guardHarness(shellInFront: boolean[]) {
const answers = [...shellInFront]
let listener: ((data: string) => void) | null = null
const unsubscribe = vi.fn(() => {
listener = null
})
const runtime: LaunchedAgentWriteGuardRuntime = {
isLaunchShellInFront: vi.fn(async () => answers.shift() ?? false),
subscribeToTerminalData: vi.fn((_ptyId: string, next: (data: string) => void) => {
listener = next
return unsubscribe
})
}
const guard = createLaunchedAgentWriteGuard(runtime, 'claude')
return { runtime, guard, unsubscribe, emit: (data: string) => listener?.(data) }
}
describe('the check before each write of a launch prompt', () => {
it('reuses a read that found no shell for the writes after it', async () => {
const { runtime, guard } = guardHarness([false])
await guard.beforeWrite('pty-1')
// The agent's own output after the paste says nothing about a shell.
await guard.beforeWrite('pty-1')
await guard.beforeWrite('pty-1')
expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(1)
})
it.each([
['a shell turning bracketed paste on', '$ \x1b[?2004h'],
['a shell integration prompt mark', '\x1b]133;D;1\x07\x1b]133;A\x07$ '],
['a mark split across two chunks', ['\x1b[?20', '04h']]
] as const)('reads again after %s, and refuses a shell', async (_label, output) => {
const { runtime, guard, emit } = guardHarness([false, true])
await guard.beforeWrite('pty-1')
for (const chunk of typeof output === 'string' ? [output] : output) {
emit(chunk)
}
await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(2)
})
it('catches a shell that returns while the first read is still running', async () => {
const { runtime, guard, emit } = guardHarness([])
vi.mocked(runtime.isLaunchShellInFront)
.mockImplementationOnce(async () => {
emit('\x1b[?2004h')
return false
})
.mockResolvedValueOnce(true)
await guard.beforeWrite('pty-1')
await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
})
it('stops watching when it refuses and when it is disposed', async () => {
const refused = guardHarness([true])
await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
expect(refused.unsubscribe).toHaveBeenCalledTimes(1)
const cleared = guardHarness([false])
await cleared.guard.beforeWrite('pty-1')
cleared.guard.dispose()
expect(cleared.unsubscribe).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,58 @@
import type { TuiAgent } from '../../shared/tui-agent'
import type { OrcaRuntimeService } from './orca-runtime'
/** A shell back at its prompt turns bracketed paste on, and Orca's shell integration marks it. */
const SHELL_RETURN_MARKERS = ['\x1b[?2004', '\x1b]133;'] as const
const MARKER_CARRY_CHARS = Math.max(...SHELL_RETURN_MARKERS.map((marker) => marker.length)) - 1
export type LaunchedAgentWriteGuardRuntime = Pick<
OrcaRuntimeService,
'isLaunchShellInFront' | 'subscribeToTerminalData'
>
/**
* The check before each write of a launch prompt: the paste, its Enter, and Codex's second Enter.
* A shell proven in front refuses the write. Once a read finds none, later writes reuse it until the
* terminal shows a shell coming back to its prompt, so Enter follows the paste on the desktop's
* timing instead of waiting out another process read.
*/
export function createLaunchedAgentWriteGuard(
runtime: LaunchedAgentWriteGuardRuntime,
agent: TuiAgent
): { beforeWrite: (ptyId: string) => Promise<void>; dispose: () => void } {
let cleared: { ptyId: string; shellMayHaveReturned: boolean; unsubscribe: () => void } | null =
null
const dispose = (): void => {
cleared?.unsubscribe()
cleared = null
}
const beforeWrite = async (ptyId: string): Promise<void> => {
if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) {
return
}
dispose()
let carry = ''
const watch = { ptyId, shellMayHaveReturned: false, unsubscribe: (): void => {} }
// Subscribed before the read, so a shell that returns while it runs is not missed.
watch.unsubscribe = runtime.subscribeToTerminalData(ptyId, (data) => {
const window = carry + data
carry = window.slice(-MARKER_CARRY_CHARS)
if (SHELL_RETURN_MARKERS.some((marker) => window.includes(marker))) {
watch.shellMayHaveReturned = true
}
})
let shellInFront: boolean
try {
shellInFront = await runtime.isLaunchShellInFront(ptyId, agent)
} catch (error) {
watch.unsubscribe()
throw error
}
if (shellInFront) {
watch.unsubscribe()
throw new Error('agent_not_in_foreground')
}
cleared = watch
}
return { beforeWrite, dispose }
}
@@ -5,6 +5,7 @@ import { navigationTargetsClients, navigationTargetsHost } from '../../shared/ru
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type {
WorktreeStartupDraftPaste,
@@ -30,6 +31,9 @@ import {
} from './runtime-worktree-startup-readiness'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning'
import { readFreshComposerHold } from './launched-agent-composer-readiness'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
import { isShellInFrontOfLaunchedAgent } from './launched-agent-shell-foreground'
export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees {
async activateManagedWorktree(
@@ -144,6 +148,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
launchInputs?: {
agentArgs?: string | null
launchSource?: string
onPromptCarry?: (carried: boolean) => void
}
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
if (!this.store) {
@@ -156,6 +161,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
...(launchPreferences ? { launchPreferences } : {}),
...(launchInputs?.agentArgs !== undefined ? { agentArgs: launchInputs.agentArgs } : {}),
...(launchInputs?.launchSource ? { launchSource: launchInputs.launchSource } : {}),
...(launchInputs?.onPromptCarry ? { onPromptCarry: launchInputs.onPromptCarry } : {}),
settings: this.store.getSettings(),
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo),
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
@@ -177,27 +183,66 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft)
}
/** Only for a newly launched worker, before its first dispatch input. */
/**
* Only for a newly launched agent, before its first input. Settles when the agent's composer
* signal fires on a screen with no startup dialog and no Codex provisional header; with
* `stopOnDialog`, a dialog ends the wait so the caller's idle wait can report it.
*/
async waitForFreshWorkerComposer(
handle: string,
agent: TuiAgent,
timeoutMs: number
): Promise<void> {
timeoutMs: number,
{
requireComposerMarker = true,
stopOnDialog = false
}: { requireComposerMarker?: boolean; stopOnDialog?: boolean } = {}
): Promise<RuntimeTerminalWait> {
const initialPtyId =
this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
const stop = new AbortController()
const ptyId = await waitForWorktreeStartupDraft(
{ ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId },
handle,
agent,
{ timeoutMs, requireComposerMarker: true }
{
timeoutMs,
requireComposerMarker,
signal: stop.signal,
isShellInFront: (ownerPtyId) => this.isLaunchShellInFront(ownerPtyId, agent),
accept: (readyPtyId) => {
const pty = this.ptysById.get(readyPtyId)
const hold = pty
? readFreshComposerHold(
buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview),
this.readLiveTerminalScreenLines(readyPtyId)
)
: null
if (hold === 'dialog' && stopOnDialog) {
stop.abort()
}
return hold === null
}
}
)
if (!ptyId) {
throw new Error('timeout')
throw new Error(stop.signal.aborted ? 'agent_startup_dialog' : 'timeout')
}
this.assertLiveTerminalHandleTargetsPty(handle, ptyId)
if (!this.ptysById.get(ptyId)?.connected) {
throw new Error('terminal_handle_stale')
}
return this.buildTuiIdleProbeResult(handle, null)
}
/** Whether a shell, not the agent a launch started, is proven to be in the terminal's foreground. */
isLaunchShellInFront(ptyId: string, agent: TuiAgent): Promise<boolean> {
const pty = this.ptysById.get(ptyId)
return isShellInFrontOfLaunchedAgent(
this.ptyController,
{ remote: !!pty?.connectionId, windows: this.pathFlavorForPty(pty) === 'win32' },
ptyId,
agent
)
}
protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void {
@@ -1,5 +1,6 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-terminal-pane'
import { wrapTerminalBracketedPasteText } from '../../shared/terminal-bracketed-paste-text'
import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core'
import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts'
import type { RuntimeTerminalSend } from '../../shared/runtime-types'
@@ -158,6 +159,10 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
): Promise<RuntimeTerminalSend> {
// Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup.
const payloadFor = (ptyId: string): string => {
// Why: a launch prompt replaced the desktop's draft paste, so it sends that paste's bytes.
if (options.inputKind === 'launch') {
return wrapTerminalBracketedPasteText(prompt)
}
const pty = this.ptysById.get(ptyId)
const agent = pty?.foregroundAgent ?? pty?.launchAgent
return buildAgentPromptPasteBytes(
@@ -134,6 +134,7 @@ export class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRuntimeWithW
condition?: RuntimeTerminalWaitCondition
timeoutMs?: number
signal?: AbortSignal
launchReadiness?: boolean
}
): Promise<RuntimeTerminalWait> {
return this.terminalWait.wait(handle, options)
@@ -62,7 +62,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
args.startupLaunchPreferences,
{
...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}),
...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {})
...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}),
...(args.onStartupPromptCarry ? { onPromptCarry: args.onStartupPromptCarry } : {})
}
)
: null
@@ -19,7 +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 { buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry'
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
@@ -215,8 +216,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
return opts
}
const startupPlan = buildAgentStartupPlan({
...resolveAgentStartupPlanInputs({
// A prompt this launch command cannot carry has nowhere to go from here — the create returns
// options, not a live PTY — so refuse rather than spawn the agent and drop the text.
if (opts.startupPrompt && !agentPromptRidesLaunchCommand(agent)) {
throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`)
}
const { plan: startupPlan, promptCarried } = planStartupWithPromptCandidate(
resolveAgentStartupPlanInputs({
agent,
settings,
platform,
@@ -226,9 +232,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
windowsShellOverride: opts.shellOverride,
sessionOptions: this.toAgentSessionOptions(opts.launchPreferences)
}),
prompt: opts.startupPrompt ?? '',
allowEmptyPromptLaunch: true
})
opts.startupPrompt ?? ''
)
if (!startupPlan) {
// Why: an explicit agent that yields no plan would otherwise spawn a bare
// shell that never reaches agent readiness.
@@ -237,10 +242,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
}
return opts
}
// A prompt this launch command cannot carry has nowhere to go from here — the create returns
// options, not a live PTY — so refuse rather than spawn the agent and drop the text.
if (opts.startupPrompt && startupPlan.followupPrompt) {
throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`)
if (opts.startupPrompt) {
opts.onStartupPromptCarry?.(promptCarried)
}
return {
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_PROMPT_BRACKETED_PASTE_END,
OrcaRuntimeService,
acknowledgeAgentPromptSubmit
} from '../orca-runtime-test-mocks.spec'
import { TEST_WORKTREE_PATH, store } from '../orca-runtime-test-fixtures.spec'
import { wrapTerminalBracketedPasteText } from '../../../shared/terminal-bracketed-paste-text'
// A launch's first prompt goes in right after the caller saw the agent's composer accept input, as
// the desktop's own draft paste did: Enter one turn after the paste, not after the render settles.
async function launchedAgentPane(agent: 'claude' | 'codex' | 'qwen-code') {
const writes: { data: string; at: number }[] = []
const runtime = new OrcaRuntimeService(store)
const startedAt = Date.now()
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push({ data, at: Date.now() - startedAt })
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
// A live Claude keeps repainting after a paste, so its render never settles.
runtime.onPtyData('pty-bg', '\x1b[?25h', Date.now())
for (let delay = 500; delay <= 9_000; delay += 500) {
setTimeout(() => runtime.onPtyData('pty-bg', `frame ${delay}`, Date.now()), delay)
}
}
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
launchAgent: agent
})
return { runtime, handle, writes }
}
describe('a launch prompt into a composer the caller saw ready', () => {
it('pastes the same bytes the desktop draft paste sent for the prompt', async () => {
vi.useFakeTimers()
try {
const { runtime, handle, writes } = await launchedAgentPane('claude')
const prompt = 'Explain this commit:\n- first line\r\n- second line with \x1b[31m colour'
const send = runtime.sendTerminalAgentPrompt(handle, prompt, {
inputKind: 'launch',
composerReady: true
})
await vi.advanceTimersByTimeAsync(100)
await send
const pasted = writes
.map((write) => write.data)
.filter((data) => data !== '\r')
.join('')
expect(pasted).toBe(wrapTerminalBracketedPasteText(prompt))
// Main's draft paste: every line break as CR, and an embedded ESC made inert.
expect(pasted).toBe(
'\x1b[200~Explain this commit:\r- first line\r- second line with \u241b[31m colour\x1b[201~'
)
} finally {
vi.useRealTimers()
}
})
it('submits one turn after the paste instead of waiting out the render cap', async () => {
vi.useFakeTimers()
try {
const { runtime, handle, writes } = await launchedAgentPane('claude')
const send = runtime.sendTerminalAgentPrompt(handle, 'fix the checks\nlog tail', {
inputKind: 'launch',
composerReady: true
})
await vi.advanceTimersByTimeAsync(49)
expect(writes.map((write) => write.data)).not.toContain('\r')
await vi.advanceTimersByTimeAsync(2)
await send
// One Enter: Claude takes no second one.
expect(writes.filter((write) => write.data === '\r')).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it.each(['codex', 'qwen-code'] as const)(
'sends %s its second Enter after its retry delay, as the desktop paste did',
async (agent) => {
vi.useFakeTimers()
try {
const { runtime, handle, writes } = await launchedAgentPane(agent)
const send = runtime.sendTerminalAgentPrompt(handle, 'explain this commit', {
inputKind: 'launch',
composerReady: true
})
await vi.advanceTimersByTimeAsync(51)
expect(writes.filter((write) => write.data === '\r')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1_200)
await send
expect(writes.filter((write) => write.data === '\r')).toHaveLength(2)
} finally {
vi.useRealTimers()
}
}
)
it('keeps a prompt into a running agent behind the render settle, as before', async () => {
vi.useFakeTimers()
try {
const { runtime, handle, writes } = await launchedAgentPane('claude')
const send = runtime.sendTerminalAgentPrompt(handle, 'fix the checks', {
inputKind: 'driving'
})
await vi.advanceTimersByTimeAsync(7_000)
expect(writes.map((write) => write.data)).not.toContain('\r')
await vi.advanceTimersByTimeAsync(2_000)
await send
expect(writes.filter((write) => write.data === '\r')).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
})
@@ -8,6 +8,7 @@ import {
waitForAgentPromptPromise
} from './orca-runtime-core'
import {
AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS,
AGENT_PROMPT_SUBMIT,
agentPromptSubmitJoinsPasteFrame,
getAgentPromptSubmitDelayMs,
@@ -20,6 +21,7 @@ import {
resolveAgentPromptEffectTimeoutMs,
verifyAgentPromptSubmission
} from './agent-prompt-submission-verification'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission {
protected async writeTerminalAgentPrompt(
@@ -43,7 +45,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
)
const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8')
const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength)
const renderGate = this.createAgentPromptRenderGate(ptyId, pasteIngestMs)
// Why no gate for a ready composer: a live Claude never settles it, so Enter always waited out
// its 8 s cap, where the desktop's own paste submitted in about 2 s.
const renderGate = options.composerReady
? null
: this.createAgentPromptRenderGate(ptyId, pasteIngestMs)
const waitTextCache: AgentPromptWaitTextCache = {}
const preSubmitBaseline = submitWithPaste
? this.getAgentPromptActivity(handle, ptyId, waitTextCache)
@@ -79,6 +85,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
} finally {
renderGate.dispose()
}
} else if (options.composerReady) {
await waitForAgentPromptDelay(
AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS + pasteIngestMs,
options.signal
)
} else {
const agent = this.getPtyAgent(ptyId)
const submitDelayMs = options.promptForSchedule
@@ -107,6 +118,10 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
throw new Error(options.suffixFailureError ?? 'terminal_not_writable')
}
}
const submits =
options.composerReady && !submitWithPaste
? 1 + (await this.resubmitAgentPromptAfterRetryDelay(ptyId, generation, options))
: 1
const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId))
if (!options.acceptQueued || !options.requestId) {
await verifyAgentPromptSubmission({
@@ -115,7 +130,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
timeoutMs: effectTimeoutMs,
signal: options.signal
})
return { submits: 1 }
return { submits }
}
const binding = this.getTerminalPromptRequestBinding(handle)
const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent
@@ -147,7 +162,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
// receipt; they must not fail a Dispatch merely because Orca cannot prove
// submission through hooks.
if (!settlementAgent) {
return { submits: 1, prompt: inputAccepted }
return { submits, prompt: inputAccepted }
}
this.registerAgentPromptRequest(
ptyId,
@@ -175,7 +190,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
})
this.forgetAgentPromptRequest(ptyId, generation, options.requestId)
return {
submits: 1,
submits,
prompt: {
...inputAccepted,
stages: ['input_accepted', 'turn_started']
@@ -183,16 +198,40 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso
}
} catch (error) {
if (error instanceof Error && error.message === 'agent_prompt_stalled') {
return { submits: 1, prompt: inputAccepted }
return { submits, prompt: inputAccepted }
}
if (error instanceof Error && error.message === 'agent_prompt_blocked') {
this.forgetAgentPromptRequest(ptyId, generation, options.requestId)
return {
submits: 1,
submits,
prompt: { ...inputAccepted, observation: 'permission' }
}
}
throw error
}
}
/**
* The desktop draft paste's second Enter, for agents whose composer can render before Enter is
* live (`submitRetryDelayMs`). Best effort: it never fails a prompt the first Enter submitted.
*/
private async resubmitAgentPromptAfterRetryDelay(
ptyId: string,
generation: number,
options: RuntimeAgentPromptWriteOptions
): Promise<number> {
const agent = this.getPtyAgent(ptyId)
const retryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined
if (retryDelayMs === undefined) {
return 0
}
try {
await waitForAgentPromptDelay(retryDelayMs, options.signal)
this.assertAgentPromptGeneration(ptyId, generation)
await options.beforeWrite?.(ptyId)
return this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind) ? 1 : 0
} catch {
return 0
}
}
}
+1
View File
@@ -47,6 +47,7 @@ await import('./orca-runtime-tests/terminal-creation-and-readiness-part-05.spec'
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-06.spec')
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-07.spec')
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-08.spec')
await import('./orca-runtime-tests/agent-launch-prompt-submit.spec')
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-09.spec')
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-10.spec')
await import('./orca-runtime-tests/terminal-creation-and-readiness-part-11.spec')
+3
View File
@@ -75,6 +75,9 @@ export type RpcContext = {
clientId?: string
// Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id.
pairedDeviceId?: string
// Why: an in-process caller (the desktop renderer over IPC) has no paired device, yet its identity
// is fixed by the host, so replay-protected operations can key on it.
trustedLocalCallerId?: string
// Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip).
clientKind?: 'mobile' | 'runtime'
// Why: negotiation is bound to the authenticated socket, never asserted by a destructive request.
@@ -8,6 +8,8 @@ export type RpcDispatchStreamingOptions = {
signal?: AbortSignal
clientId?: string
pairedDeviceId?: string
/** Set only by an in-process transport (desktop IPC); never derived from anything a peer sends. */
trustedLocalCallerId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: readonly RuntimeCapability[]
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { RpcDispatcher } from './dispatcher'
import { defineMethod, type RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only getRuntimeId on this path; any other runtime member it touched would throw rather than read a wrong value.
const RUNTIME = { getRuntimeId: () => 'test-runtime' } as OrcaRuntimeService
const INSPECT = defineMethod({
name: 'test.inspectCaller',
params: z.object({}),
handler: (_params, { trustedLocalCallerId }) => ({
trustedLocalCallerId: trustedLocalCallerId ?? null
})
})
function request(): RpcRequest {
return { id: 'req-1', authToken: 'tok', method: 'test.inspectCaller', params: {} }
}
describe('the in-process caller identity a transport vouches for', () => {
it('reaches a unary handler from the transport that set it', async () => {
const dispatcher = new RpcDispatcher({ runtime: RUNTIME, methods: [INSPECT] })
const response = await dispatcher.dispatch(request(), {
clientKind: 'runtime',
trustedLocalCallerId: 'desktop-renderer'
})
expect(response).toMatchObject({
ok: true,
result: { trustedLocalCallerId: 'desktop-renderer' }
})
})
it('is absent for a transport that set none, whatever the request carries', async () => {
const dispatcher = new RpcDispatcher({ runtime: RUNTIME, methods: [INSPECT] })
const response = await dispatcher.dispatch(
{ ...request(), params: { trustedLocalCallerId: 'desktop-renderer' } },
{ clientKind: 'runtime' }
)
expect(response).toMatchObject({ ok: true, result: { trustedLocalCallerId: null } })
})
})
+1
View File
@@ -116,6 +116,7 @@ export class RpcDispatcher {
: undefined,
requestId: request.id,
clientId: options?.clientId,
trustedLocalCallerId: options?.trustedLocalCallerId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
@@ -25,6 +25,14 @@ const createStructuredSession = vi.hoisted(() => vi.fn())
vi.mock('./structured-agent-session-create', () => ({
createStructuredAgentSessionForWorktree: createStructuredSession
}))
const deliverTerminalPrompt = vi.hoisted(() => vi.fn(async () => true))
vi.mock('./agent-launch-terminal-prompt', () => ({
deliverTerminalAgentLaunchPrompt: deliverTerminalPrompt
}))
const commitChatPrompt = vi.hoisted(() => vi.fn(async () => 'message-1'))
vi.mock('./agent-launch-structured-prompt', () => ({
commitStructuredAgentSessionLaunchPrompt: commitChatPrompt
}))
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch')
@@ -39,6 +47,8 @@ const CREATE_LAUNCH = {
target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }
}
const CALLER = 'device-1'
const REVEAL_WARNING =
'Terminal term_1 is running, but Orca could not make it discoverable. Run `orca terminal focus --terminal term_1` to reveal and focus it.'
function selectionRuntime(options: Parameters<typeof runtimeStub>[0]) {
return Object.assign(runtimeStub(options), {
@@ -59,6 +69,8 @@ function chatActivation(): unknown {
}
beforeEach(() => {
deliverTerminalPrompt.mockClear()
commitChatPrompt.mockClear()
createStructuredSession
.mockReset()
.mockResolvedValue({ ok: true, value: { sessionId: 'sess-1' } })
@@ -91,6 +103,27 @@ describe('a paired client launching into an existing workspace', () => {
)
})
// Why: a pasted prompt waits up to a minute for the agent; the caller's view must not wait with it.
it.each([
['terminal', {}, deliverTerminalPrompt],
['chat', STRUCTURED_PREFERENCE, commitChatPrompt]
] as const)(
'selects the new %s for the caller before its prompt is delivered',
async (_surface, settings, deliver) => {
const runtime = selectionRuntime({ settings, terminalPaneKey: PANE_KEY })
await launch(
{ ...EXISTING_LAUNCH, prompt: { text: 'Fix it.\nLog:', delivery: 'submit' } },
runtime
)
const selectedAt = runtime.selectCreatedMobileSessionTabForClient.mock.invocationCallOrder[0]
const deliveredAt = deliver.mock.invocationCallOrder[0]
expect(deliveredAt).toBeDefined()
expect(selectedAt).toBeLessThan(deliveredAt!)
}
)
it('still reports the launch when selecting its tab fails', async () => {
const runtime = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY })
runtime.selectCreatedMobileSessionTabForClient.mockImplementationOnce(() => {
@@ -104,6 +137,20 @@ describe('a paired client launching into an existing workspace', () => {
warn.mockRestore()
})
// Why: a headless `--serve` host has no window to reveal into; the caller mirrors the tab anyway.
it('does not pass on the host’s own reveal warning, since the caller shows the tab itself', async () => {
const runtime = selectionRuntime({
settings: {},
terminalPaneKey: PANE_KEY,
terminalWarning: REVEAL_WARNING
})
const result = await launch(EXISTING_LAUNCH, runtime)
expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' })
expect(result.warning).toBeUndefined()
})
it('selects nothing when the runtime reported no pane for the terminal', async () => {
const runtime = selectionRuntime({ settings: {} })
@@ -123,6 +170,14 @@ describe('launches that keep the host-wide behaviour', () => {
expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled()
})
it('an in-process caller still hears that the host could not reveal the tab', async () => {
const runtime = selectionRuntime({ settings: {}, terminalWarning: REVEAL_WARNING })
const result = await launch(EXISTING_LAUNCH, runtime, {})
expect(result.warning).toBe(REVEAL_WARNING)
})
it("the host's own desktop window, a runtime client with no paired device, still activates the chat", async () => {
const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE })
@@ -8,7 +8,8 @@
* what runs the new workspace's setup.
*/
import type { AgentLaunchResult, AgentLaunchTarget } from '../../../../shared/agent-launch-intent'
import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent'
import type { AgentLaunchPublishedSurface } from '../../../agent-launch/agent-launch-executor'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcContext } from '../core'
@@ -27,25 +28,25 @@ export function agentLaunchCallerNavigationId(
/** Bookkeeping, never a gate: the agent already runs, so a failure here only leaves the view as it was. */
export function selectAgentLaunchTabForCaller(
runtime: Pick<OrcaRuntimeService, 'selectCreatedMobileSessionTabForClient'>,
result: AgentLaunchResult,
surface: AgentLaunchPublishedSurface,
clientNavigationId: string
): void {
const { outcome } = result
const { outcome } = surface
// Found by pane or session, never by a predicted tab id.
const surface =
const selector =
outcome.kind === 'terminal'
? outcome.paneKey
? parsePaneKey(outcome.paneKey)
: null
: { sessionId: outcome.sessionId }
if (!surface) {
if (!selector) {
return
}
try {
if (
!runtime.selectCreatedMobileSessionTabForClient(
result.worktreeId,
surface,
surface.worktreeId,
selector,
clientNavigationId
)
) {
@@ -0,0 +1,113 @@
/**
* Whether `agent.launch` pastes an argv agent's prompt is the runtime's report about the line it
* typed, not the executor's guess: a carried prompt must not be pasted a second time, and an
* uncarried one must not be left undelivered.
*/
import { describe, expect, it, vi } from 'vitest'
import {
CAPABLE_CLIENT,
methodNamed,
rpcContext,
runtimeStub,
type AgentLaunchRuntimeStub
} from './agent-launch.test-fixture'
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch')
const SUBMIT = { text: 'fix the failing checks\nlog tail follows', delivery: 'submit' }
function withPromptWriter(runtime: AgentLaunchRuntimeStub) {
const waitForTerminal = vi.fn(async () => ({ satisfied: true, status: 'idle' }))
// The idle evidence settles these launches; the composer signal never fires.
const waitForFreshWorkerComposer = vi.fn(async () => {
throw new Error('timeout')
})
const sendTerminalAgentPrompt = vi.fn(async () => ({
handle: 'term_1',
accepted: true,
bytesWritten: 1
}))
return {
runtime: Object.assign(runtime, {
waitForTerminal,
waitForFreshWorkerComposer,
sendTerminalAgentPrompt
}),
sendTerminalAgentPrompt
}
}
async function launch(params: unknown, runtime: AgentLaunchRuntimeStub) {
const parsed = AGENT_LAUNCH.params.safeParse(params)
if (!parsed.success) {
throw new Error(parsed.error.issues[0]?.message ?? 'invalid')
}
return AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, CAPABLE_CLIENT))
}
describe('an argv agent’s launch prompt, by what the runtime reports about its typed line', () => {
const EXISTING = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } }
it('pastes it once the agent is ready when the line could not carry it', async () => {
const { runtime, sendTerminalAgentPrompt } = withPromptWriter(
runtimeStub({ settings: {}, lineCarriesPrompt: false })
)
const result = await launch({ ...EXISTING, prompt: SUBMIT }, runtime)
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('term_1', SUBMIT.text, expect.anything())
})
it('does not paste a prompt the line already carried', async () => {
const { runtime, sendTerminalAgentPrompt } = withPromptWriter(
runtimeStub({ settings: {}, lineCarriesPrompt: true })
)
const result = await launch({ ...EXISTING, prompt: SUBMIT }, runtime)
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
expect(sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('pastes into an agent-first create’s startup terminal when its line could not carry it', async () => {
const { runtime, sendTerminalAgentPrompt } = withPromptWriter(
runtimeStub({ settings: {}, lineCarriesPrompt: false })
)
const result = await launch(
{
agent: 'claude',
target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } },
prompt: SUBMIT
},
runtime
)
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' })
expect(sendTerminalAgentPrompt).toHaveBeenCalledWith(
'term_agent_first',
SUBMIT.text,
expect.anything()
)
})
it('does not paste into an agent-first create’s startup terminal whose line carried it', async () => {
const { runtime, sendTerminalAgentPrompt } = withPromptWriter(
runtimeStub({ settings: {}, lineCarriesPrompt: true })
)
await launch(
{
agent: 'claude',
target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } },
prompt: SUBMIT
},
runtime
)
expect(sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
})
@@ -184,6 +184,49 @@ describe('exactly one execution per launch operation', () => {
})
})
describe('a fresh launch writes the ledger once before its effect', () => {
it('admits and claims in one durable transaction, claimed on disk when the effect starts', async () => {
const admitAndClaim = vi.spyOn(store, 'admitAndClaimOperation')
const admit = vi.spyOn(store, 'admitOperation')
const claimOnly = vi.spyOn(store, 'claimOperation')
const runtime = runtimeStub()
let statusAtEffect: string | undefined
runtime.createManagedWorktree.mockImplementationOnce(async () => {
const persisted = await readPersistedTestAgentSessionStore(directory)
statusAtEffect =
persisted.operations[agentSessionOperationKey('device-1', OPERATION_ID)]?.outcome.status
return { worktree: { id: 'wt-new' }, startupTerminal: undefined }
})
await launch(createLaunch({ operationId: OPERATION_ID }), runtime)
expect(admitAndClaim).toHaveBeenCalledTimes(1)
expect(admit).not.toHaveBeenCalled()
expect(claimOnly).not.toHaveBeenCalled()
// A claim moves the row from `pending` to `unknown`: taken, not yet settled.
expect(statusAtEffect).toBe('unknown')
})
it('still lets exactly one of two concurrent admissions run the effect', async () => {
const admission = {
callerKey: 'device-1',
operationId: OPERATION_ID,
fingerprint: 'fp-1',
now: NOW
}
const claimAfter = (decision: { decision: string; row?: AgentSessionOperationRow }) =>
decision.decision === 'admit' ||
(decision.decision === 'replay' && decision.row?.outcome.status === 'pending')
const results = await Promise.all([
store.admitAndClaimOperation(admission, claimAfter),
store.admitAndClaimOperation(admission, claimAfter)
])
expect(results.filter((result) => result.claim?.claim === 'won')).toHaveLength(1)
})
})
describe('stable replay identity', () => {
it('replays across a bearer-credential change under the paired device subject', async () => {
const params = createLaunch({ operationId: OPERATION_ID })
@@ -29,11 +29,16 @@ import type { AgentLaunchParams } from './agent-launch-schemas'
/** Remote replay needs the paired-device subject because its bearer credential can rotate. */
export function agentLaunchOperationCallerKey(
context: Pick<RpcContext, 'pairedDeviceId' | 'clientKind'>
context: Pick<RpcContext, 'pairedDeviceId' | 'clientKind' | 'trustedLocalCallerId'>
): string {
if (context.clientKind === undefined) {
return 'trusted-local:runtime'
}
// The desktop renderer is in-process like the CLI, but advertises capabilities as a client.
const trustedLocalCallerId = context.trustedLocalCallerId?.trim()
if (trustedLocalCallerId) {
return `trusted-local:${trustedLocalCallerId}`
}
const pairedDeviceId = context.pairedDeviceId?.trim()
if (!pairedDeviceId) {
throw new Error('agent_session_identity_required')
@@ -116,7 +121,7 @@ function answerFromRecordedRow(
}
/**
* Admit, then claim.
* Admit, then claim, in one durable transaction so a launch writes the ledger once before its effect.
*
* Two steps because they answer different questions — "is this id known and consistent?" and "may
* *I* run it?" — and the second cannot be folded into the first. Admission hands two concurrent
@@ -136,12 +141,14 @@ export async function admitAgentLaunchOperation(
}
const store = await requireLaunchOperationStore(context)
const callerKey = agentLaunchOperationCallerKey(context)
const admitted = await store.admitOperation({
callerKey,
operationId,
fingerprint,
now
})
const { decision: admitted, claim } = await store.admitAndClaimOperation(
{ callerKey, operationId, fingerprint, now },
// A fresh row, or a replayed one no one has answered yet, leaves the right to run open.
(decision) =>
decision.decision === 'admit' ||
(decision.decision === 'replay' &&
answerFromRecordedRow(operationId, decision.row.outcome) === null)
)
if (admitted.decision === 'refused') {
return refusal(operationId, admitted.code, `was refused: ${admitted.code}`)
}
@@ -151,7 +158,14 @@ export async function admitAgentLaunchOperation(
return answer
}
}
const claim = await store.claimOperation({ callerKey, operationId })
// Unreachable with both steps in one transaction; answered as uncertain rather than run twice.
if (!claim || claim.claim === 'absent') {
return refusal(
operationId,
'agent_session_operation_unknown',
'has no claim; its outcome is unknown'
)
}
if (claim.claim === 'lost') {
// The handler joins same-process retries before admission. Reaching a claimed row here means
// this runtime did not start it, so treating it as restart uncertainty is the safe answer.
@@ -160,15 +174,6 @@ export async function admitAgentLaunchOperation(
refusal(operationId, 'agent_session_operation_unknown', 'is claimed but unsettled')
)
}
if (claim.claim === 'absent') {
// Admitted a moment ago and gone already: the row cannot be re-admitted without reopening the
// duplicate-spawn window it exists to close, so this stays uncertain.
return refusal(
operationId,
'agent_session_operation_unknown',
'was pruned between admission and its claim; its outcome is unknown'
)
}
return {
decision: 'execute',
attachOperationId,
@@ -42,8 +42,8 @@ export function agentLaunchSurfaceFactory(
context: RpcContext,
attachOperationId?: string,
operationCallerKey?: string,
// False when the launch selects the chat for its paired caller instead of for everyone.
activateChat = true,
// True when the launch shows its surface to the paired caller itself rather than to everyone.
callerPresentsSurface = false,
terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch()
): AgentLaunchSurfaceFactory {
return {
@@ -80,7 +80,7 @@ export function agentLaunchSurfaceFactory(
...(seeded ? { options: seeded } : {}),
...(tabId ? { tabId } : {}),
// The user asked for this chat, so it takes the surface — unlike a dispatched worker.
activate: activateChat
activate: !callerPresentsSurface
})
if (!created.ok) {
// The caller named this session, so a taken id is its answer, not an opaque refusal; and not
@@ -118,16 +118,25 @@ export function agentLaunchSurfaceFactory(
cwd,
launchSource,
paneKey,
viewMode,
options
}) => {
const launchPreferences = toAgentLaunchPreferences(options)
let promptRodeLaunchCommand = false
const created = context.runtime.createTerminal(`id:${worktreeId}`, {
// The agent id is not a shell command — `cursor` is the desktop app, its CLI is
// `cursor-agent` — so the runtime builds the configured launcher.
startupAgent: agent,
// Folded into that launcher by the same startup plan a new agent tab is built from, so an
// argv agent's prompt is in its argv at exec time rather than typed in afterwards.
...(startupPrompt ? { startupPrompt } : {}),
// Offered to that launcher's startup plan; it rides only when the typed line can carry it,
// and the runtime reports which so an uncarried prompt is pasted once the agent is ready.
...(startupPrompt
? {
startupPrompt,
onStartupPromptCarry: (carried: boolean) => {
promptRodeLaunchCommand = carried
}
}
: {}),
...(agentArgs !== undefined ? { agentArgs } : {}),
...(cwd ? { cwd } : {}),
// The model the user picked outranks configured args here too, as it does on a chat.
@@ -135,6 +144,8 @@ export function agentLaunchSurfaceFactory(
// A live reserved pane would be attached, not launched into, so the runtime refuses it.
...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}),
...(launchSource ? { launchSource } : {}),
// Not `presentation`: a focused create takes the renderer path, which drops the pane identity.
...(viewMode ? { viewMode } : {}),
onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched
})
const terminal = await created.catch(terminalSpawn.rethrow)
@@ -143,13 +154,17 @@ export function agentLaunchSurfaceFactory(
// The runtime already minted this pane and baked it into the PTY's env and its own reveal;
// dropping it here was what left a client with no way to name the tab it just asked for.
...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}),
...(terminal.warning ? { warning: terminal.warning } : {})
// Its only warning is that the host could not reveal the tab, which the caller shows itself.
...(terminal.warning && !callerPresentsSurface ? { warning: terminal.warning } : {}),
...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {})
}
},
deliverTerminalPrompt: async ({ handle, prompt }) =>
deliverTerminalPrompt: async ({ handle, agent, freshLaunch, prompt }) =>
deliverTerminalAgentLaunchPrompt({
runtime: context.runtime,
handle,
agent,
freshLaunch,
text: prompt.text
})
}
@@ -80,6 +80,16 @@ describe('a terminal launch into an existing workspace', () => {
expect(terminalOptions(runtime)).toMatchObject({ launchPreferences: PICKS })
})
it('opens the tab in the view mode the caller asked for, without asking for a focused create', async () => {
const runtime = runtimeStub({ settings: TERMINAL_ONLY })
await launch({ ...EXISTING_LAUNCH, viewMode: 'chat' }, runtime)
// A focused create takes the renderer path, which drops the caller's pane identity.
expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'chat' })
expect(terminalOptions(runtime)).not.toHaveProperty('presentation')
})
it('drops options that are not launch preferences', async () => {
const runtime = runtimeStub({ settings: TERMINAL_ONLY })
@@ -18,18 +18,46 @@ type SendFn = (
options: Record<string, unknown>
) => Promise<SendResult>
function runtimeStub(overrides: { wait?: unknown; send?: SendFn }) {
const waitForTerminal = vi.fn(async () => overrides.wait ?? { satisfied: true, status: 'idle' })
const COMPOSER_READY = { satisfied: true, status: 'running' }
function runtimeStub(overrides: {
wait?: unknown
waits?: unknown[]
send?: SendFn
/** Whether the agent's composer signal fires within its budget; else the idle evidence decides. */
composerSignal?: boolean
/** Whether a shell is proven in the terminal's foreground. */
shellInFront?: boolean
}) {
const queued = [...(overrides.waits ?? [])]
const waitForTerminal = vi.fn(
async (_handle: string, _options?: { condition?: string; timeoutMs?: number }) =>
queued.shift() ?? overrides.wait ?? { satisfied: true, status: 'idle' }
)
const waitForFreshWorkerComposer = vi.fn(async () => {
if (!overrides.composerSignal) {
throw new Error('timeout')
}
return COMPOSER_READY
})
const isLaunchShellInFront = vi.fn(async () => overrides.shellInFront ?? false)
const subscribeToTerminalData = vi.fn(() => () => {})
const sendTerminalAgentPrompt = vi.fn<SendFn>(
overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 }))
)
return {
waitForTerminal,
waitForFreshWorkerComposer,
sendTerminalAgentPrompt,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches exactly these two runtime methods; anything else would throw rather than read a wrong value.
runtime: { waitForTerminal, sendTerminalAgentPrompt } as unknown as Parameters<
typeof deliverTerminalAgentLaunchPrompt
>[0]['runtime']
isLaunchShellInFront,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches exactly these five runtime methods; anything else would throw rather than read a wrong value.
runtime: {
waitForTerminal,
waitForFreshWorkerComposer,
sendTerminalAgentPrompt,
isLaunchShellInFront,
subscribeToTerminalData
} as unknown as Parameters<typeof deliverTerminalAgentLaunchPrompt>[0]['runtime']
}
}
@@ -48,13 +76,22 @@ describe('writing a launch prompt into a terminal agent', () => {
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing'
})
expect(delivered).toBe(true)
// The composer signal did not fire within its budget, so the idle evidence decided.
expect(stub.waitForFreshWorkerComposer).toHaveBeenCalledWith('term_1', 'claude', 8_000, {
requireComposerMarker: false,
stopOnDialog: true
})
expect(stub.waitForTerminal).toHaveBeenCalledWith('term_1', {
condition: 'tui-idle',
timeoutMs: 60_000
timeoutMs: expect.any(Number),
// A name-only title proves nothing about a just-launched agent until its stream is quiet.
launchReadiness: true
})
const [handle, text, options] = stub.sendTerminalAgentPrompt.mock.calls[0]!
expect(handle).toBe('term_1')
@@ -63,6 +100,8 @@ describe('writing a launch prompt into a terminal agent', () => {
// agent would be reported as undelivered while its prompt sat in the pane.
expect(options.acceptQueued).toBe(true)
expect(options.requestId).toEqual(expect.any(String))
// Enter follows the paste on the desktop draft paste's timing: this composer was just seen ready.
expect(options.composerReady).toBe(true)
})
it('does not write when the composer never opened', async () => {
@@ -70,6 +109,8 @@ describe('writing a launch prompt into a terminal agent', () => {
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing'
})
@@ -87,6 +128,8 @@ describe('writing a launch prompt into a terminal agent', () => {
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing'
})
@@ -103,6 +146,8 @@ describe('writing a launch prompt into a terminal agent', () => {
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing'
})
@@ -115,6 +160,8 @@ describe('writing a launch prompt into a terminal agent', () => {
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing'
})
@@ -128,10 +175,220 @@ describe('writing a launch prompt into a terminal agent', () => {
await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: ' '
})
).toBe(false)
expect(stub.waitForTerminal).not.toHaveBeenCalled()
expect(stub.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('waits out a blocking prompt the user dismisses, then writes', async () => {
const blocked = { satisfied: false, status: 'running', blockedReason: 'trust-prompt' }
const stub = runtimeStub({ waits: [blocked, blocked, { satisfied: true, status: 'running' }] })
const clock = fakeClock()
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing',
clock
})
expect(delivered).toBe(true)
expect(stub.waitForTerminal).toHaveBeenCalledTimes(3)
// Each re-wait spends only what is left of the one launch budget.
const lastTimeoutMs = stub.waitForTerminal.mock.calls.at(-1)?.[1]?.timeoutMs ?? 0
expect(lastTimeoutMs).toBeLessThanOrEqual(58_000)
expect(lastTimeoutMs).toBeGreaterThan(57_000)
expect(stub.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1)
})
it('writes nothing into a blocking prompt still up when the budget ends', async () => {
const stub = runtimeStub({
wait: { satisfied: false, status: 'running', blockedReason: 'trust-prompt' }
})
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing',
clock: fakeClock()
})
expect(delivered).toBe(false)
expect(stub.sendTerminalAgentPrompt).not.toHaveBeenCalled()
// One check per second of a 60 s budget, then it stops rather than spinning.
expect(stub.waitForTerminal.mock.calls.length).toBeLessThanOrEqual(60)
})
it.each([
['refuses the write when the shell is back in front', true, false],
// An unread foreground is no proof the agent exited; the composer was just seen ready.
['writes unless a shell is proven in front', false, true]
])('%s', async (_label, shellInFront, delivered) => {
// An agent that exits at startup hands its shell back, which must never run the prompt.
const stub = runtimeStub({ composerSignal: true, shellInFront })
stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => {
const { beforeWrite } = options
if (typeof beforeWrite === 'function') {
await beforeWrite('pty-1')
}
return { handle, accepted: true, bytesWritten: 12 }
})
await expect(
deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing'
})
).resolves.toBe(delivered)
expect(stub.isLaunchShellInFront).toHaveBeenCalledWith('pty-1', 'claude')
})
it('checks the foreground once for the paste, its Enter and the second Enter', async () => {
const stub = runtimeStub({ composerSignal: true })
stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => {
const { beforeWrite } = options
if (typeof beforeWrite === 'function') {
await beforeWrite('pty-1')
await beforeWrite('pty-1')
await beforeWrite('pty-1')
}
return { handle, accepted: true, bytesWritten: 12 }
})
await expect(
deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'codex',
freshLaunch: true,
text: 'do the thing'
})
).resolves.toBe(true)
expect(stub.isLaunchShellInFront).toHaveBeenCalledTimes(1)
})
it('writes as soon as the composer signal fires, without consulting the idle evidence', async () => {
const stub = runtimeStub({ composerSignal: true })
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing'
})
expect(delivered).toBe(true)
expect(stub.waitForFreshWorkerComposer).toHaveBeenCalledWith('term_1', 'claude', 8_000, {
requireComposerMarker: false,
stopOnDialog: true
})
expect(stub.waitForTerminal).not.toHaveBeenCalled()
})
it.each(['zcode', 'dsh', 'grok'] as const)('waits for %s’s composer readiness', async (agent) => {
const stub = runtimeStub({ composerSignal: true })
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent,
freshLaunch: true,
text: 'do the thing'
})
expect(delivered).toBe(true)
expect(stub.waitForFreshWorkerComposer).toHaveBeenCalledWith(
'term_1',
agent,
60_000,
expect.anything()
)
expect(stub.waitForTerminal).not.toHaveBeenCalled()
})
it('keeps the text when an agent shows no readiness evidence at all', async () => {
// Nothing is pasted blind.
const stub = runtimeStub({ wait: { satisfied: false, status: 'running' } })
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'goose',
freshLaunch: true,
text: 'do the thing'
})
expect(delivered).toBe(false)
expect(stub.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
it('never lets a re-wait fall back to the terminal wait’s 5-minute default', async () => {
// A late 1 s re-check sleep can land past the deadline; 0 would read as "use the default".
const stub = runtimeStub({
wait: { satisfied: false, status: 'running', blockedReason: 'trust-prompt' }
})
let now = 0
const lateClock = {
now: () => now,
sleep: async (ms: number) => {
now += ms + 1_000
}
}
await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_1',
agent: 'claude',
freshLaunch: true,
text: 'do the thing',
clock: lateClock
})
for (const [, options] of stub.waitForTerminal.mock.calls) {
expect(options?.timeoutMs).toBeGreaterThan(0)
}
})
it('waits on a reused terminal’s idle state, not a fresh launch’s composer marker', async () => {
// A long-running grok pane may no longer show its composer marker in recent output.
const stub = runtimeStub({})
const delivered = await deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_existing',
agent: 'grok',
freshLaunch: false,
text: 'do the thing'
})
expect(delivered).toBe(true)
expect(stub.waitForFreshWorkerComposer).not.toHaveBeenCalled()
expect(stub.waitForTerminal).toHaveBeenCalledWith('term_existing', {
condition: 'tui-idle',
timeoutMs: 60_000
})
// A reused pane's render state is only inferred, so its Enter still waits for the render.
expect(stub.sendTerminalAgentPrompt.mock.calls[0]?.[2]?.composerReady).toBe(false)
})
})
function fakeClock() {
let now = 0
return {
now: () => now,
sleep: async (ms: number) => {
now += ms
}
}
}
@@ -7,11 +7,11 @@
* Mobile, the CLI and orchestration got an agent and no prompt. The host owns the PTY, so it can
* write into one whether or not any window is open on it.
*
* This is the half argv cannot serve. An agent whose CLI takes the prompt as an argument gets it on
* the launch command instead (`agentPromptRidesLaunchCommand`), where it is in the process's argv
* at exec time and no readiness race exists. What reaches here is a `stdin-after-start` agent,
* whose CLI accepts no such argument, and a reused terminal, whose process was already running
* before this launch existed.
* This is the half the launch command cannot serve. An argv agent's prompt rides that command when
* its typed line can carry it (`startup-line-prompt-carry`), and then no readiness race exists. What
* reaches here is a `stdin-after-start` agent, whose CLI accepts no such argument; a prompt too long
* or multi-line for the typed line; and a reused terminal, whose process was already running before
* this launch existed. Readiness is `waitForLaunchedAgentComposer`, the one the worker start uses.
*
* Nothing here writes to a PTY itself. `sendTerminalAgentPrompt` is the runtime's one agent-prompt
* writer: it frames the text as a bracketed paste so multi-line and special-character content is
@@ -22,13 +22,64 @@
*/
import { randomUUID } from 'node:crypto'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { RuntimeTerminalWait } from '../../../../shared/runtime-terminal-contracts'
import { isAgentPromptStalledError } from '../../agent-prompt-submission-verification'
import {
waitForLaunchedAgentComposer,
type LaunchedAgentReadinessRuntime
} from '../../launched-agent-composer-readiness'
import type { OrcaRuntimeService } from '../../orca-runtime'
import {
createLaunchedAgentWriteGuard,
type LaunchedAgentWriteGuardRuntime
} from '../../launched-agent-write-guard'
/** The same budget orchestration gives a worker to reach its composer before dispatching to it. */
const AGENT_READY_TIMEOUT_MS = 60_000
/** How often a launch re-checks a blocking prompt the user may still dismiss. */
const BLOCKED_RECHECK_MS = 1_000
type TerminalPromptRuntime = Pick<OrcaRuntimeService, 'waitForTerminal' | 'sendTerminalAgentPrompt'>
type TerminalPromptRuntime = LaunchedAgentReadinessRuntime &
LaunchedAgentWriteGuardRuntime &
Pick<OrcaRuntimeService, 'sendTerminalAgentPrompt'>
type ReadinessClock = { now: () => number; sleep: (ms: number) => Promise<void> }
const REAL_CLOCK: ReadinessClock = {
now: () => Date.now(),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* The launched agent's readiness, waiting out a blocking prompt until the budget ends.
*
* A trust or update dialog is the user's to answer, and they often do within seconds; giving up on
* the first sight of it dropped a prompt the pane was about to accept. A dialog still up when the
* budget ends is reported as it is, and nothing is written into it.
*/
async function waitThroughBlockingPrompts(
runtime: TerminalPromptRuntime,
handle: string,
agent: TuiAgent,
freshLaunch: boolean,
clock: ReadinessClock
): Promise<RuntimeTerminalWait | undefined> {
const deadline = clock.now() + AGENT_READY_TIMEOUT_MS
for (;;) {
// At least 1 ms: the terminal wait reads 0 as "use the 5-minute default", and a late sleep can
// land past the deadline.
const timeoutMs = Math.max(1, deadline - clock.now())
const wait = freshLaunch
? await waitForLaunchedAgentComposer(runtime, handle, agent, timeoutMs)
: // A reused pane was not freshly launched: its composer marker may be long gone.
await runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs })
if (!wait?.blockedReason || wait.satisfied || deadline - clock.now() <= BLOCKED_RECHECK_MS) {
return wait
}
await clock.sleep(BLOCKED_RECHECK_MS)
}
}
/**
* Whether the text reached the pane.
@@ -47,18 +98,28 @@ type TerminalPromptRuntime = Pick<OrcaRuntimeService, 'waitForTerminal' | 'sendT
export async function deliverTerminalAgentLaunchPrompt(args: {
runtime: TerminalPromptRuntime
handle: string
agent: TuiAgent
/** False for a reused terminal, whose agent was already running before this launch. */
freshLaunch: boolean
text: string
clock?: ReadinessClock
}): Promise<boolean> {
if (args.text.trim().length === 0) {
return false
}
// Before the paste and again before Enter: an agent that exited in between leaves its shell in
// the foreground, which must never receive the prompt or its Enter.
const guard = args.freshLaunch ? createLaunchedAgentWriteGuard(args.runtime, args.agent) : null
try {
const wait = await args.runtime.waitForTerminal(args.handle, {
condition: 'tui-idle',
timeoutMs: AGENT_READY_TIMEOUT_MS
})
// An unsatisfied wait is a composer that never opened — a trust prompt, an update prompt, a
// dead process. Pasting anyway would answer whatever question is on screen with the prompt.
const wait = await waitThroughBlockingPrompts(
args.runtime,
args.handle,
args.agent,
args.freshLaunch,
args.clock ?? REAL_CLOCK
)
// An unsatisfied wait is a composer that never opened — a dialog left up, a dead process, an
// agent that showed no readiness. Pasting anyway would answer whatever is on screen with it.
if (wait && !wait.satisfied) {
console.warn(
`[agent-launch] the terminal agent did not become ready (${wait.status}); its launch prompt was not delivered`
@@ -67,6 +128,9 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
}
const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, {
inputKind: 'launch',
// A fresh launch's composer was just seen ready; a reused pane's state is only inferred.
composerReady: args.freshLaunch,
...(guard ? { beforeWrite: guard.beforeWrite } : {}),
// Paired: together these take the queued path, which settles an unobserved turn start into
// an `input_accepted` receipt rather than raising it. Without the id the write is verified
// strictly and a slow first turn throws.
@@ -86,5 +150,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
error
)
return false
} finally {
guard?.dispose()
}
}
@@ -47,6 +47,7 @@ export function agentLaunchWorkspaceFactory(
options
}) => {
const startupLaunchPreferences = toAgentLaunchPreferences(options)
let promptRodeLaunchCommand = false
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`; the executor only removed the reserved agent fields, so the rest of the payload is the parsed shape.
const params = create as WorktreeCreateParams
const { runtime } = context
@@ -65,8 +66,8 @@ export function agentLaunchWorkspaceFactory(
...params,
...(startupAgent ? { startupAgent } : {}),
// Only ever set alongside `startupAgent`, which is what the create requires; the
// executor sends it exclusively for an agent that takes its prompt on argv, so this
// is the startup command carrying the text rather than a second delivery path.
// executor offers it only to an agent that takes its prompt on argv, and it rides only
// when the typed line can carry it.
...(startupPrompt ? { startupPrompt } : {})
},
{
@@ -80,6 +81,13 @@ export function agentLaunchWorkspaceFactory(
context.clientKind ? { clientKind: context.clientKind } : {}
),
...(agentArgs !== undefined ? { startupAgentArgs: agentArgs } : {}),
...(startupPrompt
? {
onStartupPromptCarry: (carried: boolean) => {
promptRodeLaunchCommand = carried
}
}
: {}),
...(cwd ? { startupCwd: cwd } : {}),
...(launchSource ? { startupLaunchSource: launchSource } : {}),
...(paneKey ? { startupPaneKey: paneKey } : {}),
@@ -100,6 +108,7 @@ export function agentLaunchWorkspaceFactory(
return {
worktreeId: result.worktree.id,
startupTerminalHandle: result.startupTerminal?.handle,
...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}),
...(result.startupTerminal?.paneKey
? { startupTerminalPaneKey: result.startupTerminal.paneKey }
: {}),
@@ -35,6 +35,18 @@ export type AgentLaunchRuntimeStubOptions = {
startupTerminalPaneKey?: string
/** The reserved pane is already live, so a create that requires a fresh pane is refused. */
terminalPaneAlreadyLive?: boolean
/** What the runtime reports about an offered prompt's typed line; unset reports nothing. */
lineCarriesPrompt?: boolean
}
function reportPromptCarry(
options: AgentLaunchRuntimeStubOptions,
report: unknown,
offered: unknown
): void {
if (typeof report === 'function' && offered && options.lineCarriesPrompt !== undefined) {
report(options.lineCarriesPrompt)
}
}
export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) {
@@ -66,22 +78,26 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) {
}
),
showRepo: vi.fn(async () => ({ id: 'repo-1' })),
createManagedWorktree: vi.fn(async (args: Record<string, unknown>) => ({
worktree: { id: 'wt-new' },
startupTerminal: args.startupAgent
? {
handle: 'term_agent_first',
...(options.startupTerminalPaneKey ? { paneKey: options.startupTerminalPaneKey } : {})
}
: undefined,
...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {}),
...(options.createWarning ? { warning: options.createWarning } : {})
})),
createManagedWorktree: vi.fn(async (args: Record<string, unknown>) => {
reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt)
return {
worktree: { id: 'wt-new' },
startupTerminal: args.startupAgent
? {
handle: 'term_agent_first',
...(options.startupTerminalPaneKey ? { paneKey: options.startupTerminalPaneKey } : {})
}
: undefined,
...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {}),
...(options.createWarning ? { warning: options.createWarning } : {})
}
}),
// Args are declared so a test can assert what the launch asked for, not merely that it asked.
createTerminal: vi.fn(async (_selector: string, createOptions?: Record<string, unknown>) => {
if (options.terminalPaneAlreadyLive && createOptions?.requireFreshPane === true) {
throw new AgentLaunchPaneAlreadyLiveError()
}
reportPromptCarry(options, createOptions?.onStartupPromptCarry, createOptions?.startupPrompt)
return {
handle: 'term_1',
...(options.terminalPaneKey ? { paneKey: options.terminalPaneKey } : {}),
+11 -7
View File
@@ -105,6 +105,7 @@ async function agentLaunchIntent(
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.launchSource ? { launchSource: params.launchSource } : {}),
...(params.paneKey ? { paneKey: params.paneKey } : {}),
...(params.viewMode ? { viewMode: params.viewMode } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {})
}
}
@@ -150,22 +151,25 @@ async function runAgentLaunch(
terminalSpawn?: TerminalSpawnDispatch
): Promise<AgentLaunchResult> {
const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context)
const result = await executeAgentLaunch({
return executeAgentLaunch({
runtime: context.runtime,
intent,
surfaces: agentLaunchSurfaceFactory(
context,
attachOperationId,
operationCallerKey,
callerNavigationId === null,
callerNavigationId !== null,
terminalSpawn
),
workspaces: agentLaunchWorkspaceFactory(context, intent.agent)
workspaces: agentLaunchWorkspaceFactory(context, intent.agent),
// The tab is shown as it is published, not after a prompt that can take a minute to land.
...(callerNavigationId !== null
? {
onSurfacePublished: (surface) =>
selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId)
}
: {})
})
if (callerNavigationId !== null) {
selectAgentLaunchTabForCaller(context.runtime, result, callerNavigationId)
}
return result
}
/**
@@ -29,6 +29,7 @@ import { tearDownFailedWorkerStart } from './failed-worker-start-teardown'
import { requireWorkerAuthority, type WorkerEffect } from './worker-topology'
import { prepareLocalWorkerStart } from './worker-start-validation'
import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement'
import { waitForWorkerStartComposer } from '../../../../launched-agent-composer-readiness'
type WorkerStartMutation = {
callerFingerprint: string
@@ -188,17 +189,18 @@ export async function startLocalWorker(args: {
effects,
timeoutMs: params.timeoutMs ?? 60_000
})
: // ZCode emits SessionStart only after input; its first dispatch must wait for the composer.
agent === 'zcode' && !params.terminal
? await runtime.waitForFreshWorkerComposer(
: // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone.
params.terminal || !agent
? await runtime.waitForTerminal(terminalHandle, {
condition: 'tui-idle',
timeoutMs: params.timeoutMs ?? 60_000
})
: await waitForWorkerStartComposer(
runtime,
terminalHandle,
agent,
params.timeoutMs ?? 60_000
)
: await runtime.waitForTerminal(terminalHandle, {
condition: 'tui-idle',
timeoutMs: params.timeoutMs ?? 60_000
})
if (wait) {
persistWorkerSetupWaitOutcome({ ...setupStage, wait })
if (!wait.satisfied) {
@@ -390,6 +390,25 @@ describe('orchestration new-worktree workers', () => {
)
})
// Why: a first dispatch the composer signal settles must record setup exactly as one the idle
// wait settles; the composer lane once returned nothing and skipped this record.
it('settles a fresh worker start on main’s idle wait, not the launch paste’s signal', async () => {
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' })
const composerSignal = vi.spyOn(runtime, 'waitForFreshWorkerComposer')
const { result } = await startWorker()
expect(composerSignal).not.toHaveBeenCalled()
expect(runtime.waitForTerminal).toHaveBeenCalledWith(
'term_worker',
expect.objectContaining({ condition: 'tui-idle', launchReadiness: true })
)
expect(result).toMatchObject({
state: 'ready',
setup: { startupPolicy: 'wait-for-setup', state: 'succeeded' }
})
})
it('does not inject task input when the gated setup terminal fails to start', async () => {
mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'spawn_failed' })
vi.mocked(runtime.waitForTerminal).mockResolvedValue({
@@ -1,28 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types'
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
describe('ZCode first dispatch readiness', () => {
describe('composer-marker first dispatch readiness', () => {
const h = createOrchestrationWorkerReleaseHarness()
afterEach(() => h.cleanup())
it('waits for the new composer before delivering exactly one dispatch', async () => {
h.setup()
const gate = h.deferred<void>()
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise)
const pending = h.startWorker({ agent: 'zcode' })
await vi.waitFor(() =>
expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith(
'term_worker',
'zcode',
60_000
// DSH's idle hook fires only after a turn, and Grok's only other signal is its bare name, which a
// shell auto-title also writes; like ZCode, their captured composer is their readiness.
it.each(['zcode', 'dsh', 'grok'] as const)(
'waits for %s’s new composer before delivering exactly one dispatch',
async (agent) => {
h.setup()
const gate = h.deferred<RuntimeTerminalWait>()
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise)
const pending = h.startWorker({ agent })
await vi.waitFor(() =>
expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith(
'term_worker',
agent,
60_000,
expect.anything()
)
)
)
expect(h.runtime.waitForTerminal).not.toHaveBeenCalled()
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
gate.resolve()
await pending
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
})
expect(h.runtime.waitForTerminal).not.toHaveBeenCalled()
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
gate.resolve({
handle: 'term_worker',
condition: 'tui-idle',
satisfied: true,
status: 'running',
exitCode: null
})
await pending
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
}
)
it('keeps reused terminals on the normal idle wait', async () => {
h.setup()
@@ -138,6 +138,7 @@ export class RpcStreamingDispatcher {
subscriptionRegistrationVersion,
clientId: options?.clientId,
pairedDeviceId: options?.pairedDeviceId,
trustedLocalCallerId: options?.trustedLocalCallerId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
@@ -192,6 +193,7 @@ export class RpcStreamingDispatcher {
connectionId: options?.connectionId,
clientId: options?.clientId,
pairedDeviceId: options?.pairedDeviceId,
trustedLocalCallerId: options?.trustedLocalCallerId,
clientKind: options?.clientKind,
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
@@ -51,6 +51,9 @@ export type RuntimeManagedWorktreeCreateArgs = {
startupAgent?: TuiAgent
startupLaunchPreferences?: AgentLaunchPreferences
startupPrompt?: string
/** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself, so the text
* rides only a typed line that can carry it; reports whether it did. */
onStartupPromptCarry?: (carried: boolean) => void
/** Per-launch inputs used when `startupAgent` is the created terminal surface. */
startupAgentArgs?: string | null
startupCwd?: string
+12 -2
View File
@@ -42,12 +42,16 @@ export type TerminalCreateOptions = {
launchAgent?: TuiAgent
startupAgent?: TuiAgent
/**
* Initial text folded into `startupAgent`'s launch command, for an agent whose CLI takes a prompt
* Initial text offered to `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. Post-start delivery belongs to whoever owns the live PTY.
* the prompt silently dropped. The text rides only when the typed line can carry it
* (`startup-line-prompt-carry`); otherwise the agent starts clean, `onStartupPromptCarry` says so,
* and post-start delivery belongs to whoever owns the live PTY.
*/
startupPrompt?: string
/** Main-internal: whether `startupPrompt` rode the launch command. Called once the plan is built. */
onStartupPromptCarry?: (carried: boolean) => void
/**
* Replaces the Settings launch arguments for this `startupAgent` only; `null` means none at all.
*
@@ -200,6 +204,9 @@ export type TerminalWaiter = {
/** Retires this waiter from the shared idle-poll sweep; null when not polling. */
cancelIdlePoll: (() => void) | null
abortCleanup: (() => void) | null
/** Main-internal: waiting for a just-launched agent, so a name-only title must be held to a
* quiet stream (`TuiIdleEvaluationInput.launchReadiness`). */
launchReadiness?: boolean
}
/** How a provider-held screen should be fetched when runtime bytes are absent. */
@@ -215,6 +222,9 @@ export type RuntimeAgentPromptWriteOptions = Omit<RuntimeTerminalWriteOptions, '
inputKind: Exclude<TerminalInputKind, 'query-reply'>
/** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */
promptForSchedule?: string
/** The caller just saw this agent's composer accept input, so Enter follows the paste on the
* desktop draft paste's timing instead of waiting for the render to settle. */
composerReady?: boolean
/** See buildAgentPromptPasteBytes. */
leadLine?: string
/** Return an accepted receipt as soon as input lands, instead of waiting for the turn. */
@@ -133,7 +133,10 @@ export class RuntimeTerminalIdlePolls {
const readWaitText = () =>
buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
return {
verdict: evaluateTuiIdle(ptyTuiIdleEvidence(this.deps, pty, readWaitText)),
verdict: evaluateTuiIdle({
...ptyTuiIdleEvidence(this.deps, pty, readWaitText),
launchReadiness: entry.waiter.launchReadiness
}),
ptyId: pty.ptyId,
ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty),
blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason),
@@ -152,7 +155,10 @@ export class RuntimeTerminalIdlePolls {
buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
const live = () => this.deps.getLiveLeaf(entry.leaf)
return {
verdict: evaluateTuiIdle(leafTuiIdleEvidence(this.deps, leaf, readWaitText)),
verdict: evaluateTuiIdle({
...leafTuiIdleEvidence(this.deps, leaf, readWaitText),
launchReadiness: entry.waiter.launchReadiness
}),
ptyId: leaf.ptyId,
ready: () => buildTerminalWaitResult(handle, 'tui-idle', live()),
blocked: (reason) => buildTerminalWaitBlockedResult(handle, 'tui-idle', live(), reason),
+6 -2
View File
@@ -58,6 +58,8 @@ export class RuntimeTerminalWait {
condition?: RuntimeTerminalWaitCondition
timeoutMs?: number
signal?: AbortSignal
/** Main-internal, never on the wire: see `TerminalWaiter.launchReadiness`. */
launchReadiness?: boolean
}
): Promise<RuntimeTerminalWaitResult> {
const condition = options?.condition ?? 'exit'
@@ -94,7 +96,8 @@ export class RuntimeTerminalWait {
reject,
timeout: null,
cancelIdlePoll: null,
abortCleanup: null
abortCleanup: null,
...(options?.launchReadiness ? { launchReadiness: true } : {})
}
if (!this.waiters.bindAbort(waiter, options?.signal)) {
reject(new Error('request_aborted'))
@@ -184,7 +187,8 @@ export class RuntimeTerminalWait {
reject,
timeout: null,
cancelIdlePoll: null,
abortCleanup: null
abortCleanup: null,
...(options?.launchReadiness ? { launchReadiness: true } : {})
}
if (!this.waiters.bindAbort(waiter, options?.signal)) {
@@ -108,6 +108,33 @@ describe('buildWorktreeStartupForAgent host resolution', () => {
})
})
describe('buildWorktreeStartupForAgent prompt carry', () => {
const build = (onPromptCarry?: (carried: boolean) => void) =>
buildWorktreeStartupForAgent({
repo: makeRepo({}),
settings,
agent: 'claude',
prompt: 'summarize the diff\nthen list the risks',
getLaunchPlatform: () => 'linux',
toSessionOptions: () => undefined,
...(onPromptCarry ? { onPromptCarry } : {})
})
it('starts clean and reports it when a caller that pastes offers a prompt the line cannot carry', () => {
const onPromptCarry = vi.fn()
const result = build(onPromptCarry)
expect(result.startup.command).not.toContain('summarize')
expect(result.followup).toBeUndefined()
expect(onPromptCarry).toHaveBeenCalledWith(false)
})
it('keeps folding the prompt for a caller that delivers nothing afterwards', () => {
// `orca worktree create --prompt` has no post-start paste of its own for an argv agent.
expect(build().startup.command).toContain('summarize the diff')
})
})
describe('buildWorktreeStartupForDraft agent detection', () => {
it('probes the SSH host named only by executionHostId instead of this client', async () => {
mocks.detectRemoteAgents.mockResolvedValueOnce(['claude'])
@@ -9,6 +9,7 @@ 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 { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry'
import {
detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents
@@ -127,6 +128,9 @@ export function buildWorktreeStartupForAgent(
toSessionOptions: (
preferences?: AgentLaunchPreferences
) => Parameters<typeof buildAgentStartupPlan>[0]['sessionOptions'] | undefined
/** Set by a caller that delivers an uncarried prompt itself: the prompt then rides only a typed
* line that can carry it, and this reports whether it did. Absent keeps the CLI's fold. */
onPromptCarry?: (carried: boolean) => void
}
): {
agent: TuiAgent
@@ -137,18 +141,25 @@ export function buildWorktreeStartupForAgent(
if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) {
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const startupPlan = buildAgentStartupPlan({
...resolveAgentStartupPlanInputs({
agent,
settings,
platform: environment.getLaunchPlatform(),
isRemote: repoIsRemote(repo),
...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}),
sessionOptions: environment.toSessionOptions(environment.launchPreferences)
}),
prompt: environment.prompt ?? '',
allowEmptyPromptLaunch: true
const planInputs = resolveAgentStartupPlanInputs({
agent,
settings,
platform: environment.getLaunchPlatform(),
isRemote: repoIsRemote(repo),
...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}),
sessionOptions: environment.toSessionOptions(environment.launchPreferences)
})
const prompt = environment.prompt ?? ''
let startupPlan: ReturnType<typeof buildAgentStartupPlan>
if (environment.onPromptCarry) {
const offered = planStartupWithPromptCandidate(planInputs, prompt)
startupPlan = offered.plan
if (startupPlan && prompt.trim()) {
environment.onPromptCarry(offered.promptCarried)
}
} else {
startupPlan = buildAgentStartupPlan({ ...planInputs, prompt, allowEmptyPromptLaunch: true })
}
if (!startupPlan) {
throw new Error(`Could not build launch command for ${agent}.`)
}
@@ -13,6 +13,9 @@ import type {
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
const BRACKETED_PASTE_QUIET_MS = 1500
// Why: an interactive shell turns bracketed paste on at its prompt and off when it runs the typed
// command (`zsh-prompt-runs-command.txt`), so a 2004 before the last `?2004l` is the shell's.
const DECRST_BRACKETED_PASTE = '\x1b[?2004l'
export type WorktreeStartupReadinessHost = {
getPtyId: (handle: string) => string | null
@@ -87,24 +90,47 @@ export async function waitForWorktreeStartupFollowup(
return null
}
export type StartupDraftReadinessOptions = {
timeoutMs?: number
requireComposerMarker?: boolean
signal?: AbortSignal
/** Vetoes a ready signal whose screen still holds something the input must not answer; the
* scan continues, so the agent's next marker or quiet window asks again. */
accept?: (ptyId: string) => boolean | Promise<boolean>
/**
* For a launched agent. With it, only output after the shell's last `?2004l` counts, since the
* shell's own prompt enables bracketed paste too, and a ready signal is dropped while a shell is
* proven in front: the launch line has not run yet, or the agent exited.
*/
isShellInFront?: (ptyId: string) => Promise<boolean>
}
export function waitForWorktreeStartupDraft(
host: WorktreeStartupReadinessHost,
handle: string,
agent: TuiAgent,
options: { timeoutMs?: number; requireComposerMarker?: boolean } = {}
options: StartupDraftReadinessOptions = {}
): Promise<string | null> {
const ptyId = host.getPtyId(handle)
if (!ptyId) {
if (!ptyId || options.signal?.aborted) {
return Promise.resolve(null)
}
const signal =
TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const isShellInFront = options.isShellInFront
return new Promise((resolve) => {
let settled = false
const scanner = createDraftPasteReadyScanner(signal)
let scanner = createDraftPasteReadyScanner(signal)
let quietTimer: NodeJS.Timeout | null = null
let hardTimer: NodeJS.Timeout | null = null
let unsubscribe: (() => void) | null = null
// Bumped at each shell hand-off: a check begun before one must not settle the wait after it.
let handoffs = 0
let handoffCarry = ''
let checking = false
/** The hand-off count at a signal that fired while a check ran. */
let recheckAt: number | null = null
const onAbort = (): void => finish(null)
const finish = (value: string | null): void => {
if (settled) {
return
@@ -117,23 +143,74 @@ export function waitForWorktreeStartupDraft(
clearTimeout(hardTimer)
}
unsubscribe?.()
options.signal?.removeEventListener('abort', onAbort)
resolve(value)
}
const observe = (data: string): void => {
/** Settles a fired signal once its screen is clear and no shell is proven in front. */
const settleSignal = async (signalHandoffs: number): Promise<void> => {
checking = true
try {
if (options.accept && !(await options.accept(ptyId))) {
return
}
if (isShellInFront && (await isShellInFront(ptyId))) {
return
}
if (signalHandoffs === handoffs) {
finish(ptyId)
}
} catch {
// A check that failed is no settle; the agent's next signal asks again.
} finally {
checking = false
const next = recheckAt
recheckAt = null
if (next === handoffs && !settled) {
void settleSignal(next)
}
}
}
const onSignal = (): void => {
if (checking) {
recheckAt = handoffs
return
}
void settleSignal(handoffs)
}
/** The part of a chunk after the shell's last hand-off in it, resetting the scan at one. */
const sinceShellHandoff = (chunk: string): string => {
const window = handoffCarry + chunk
// Why 7: one short of the sequence, so a split one is rejoined and never counted twice.
handoffCarry = window.slice(-(DECRST_BRACKETED_PASTE.length - 1))
const handoff = window.lastIndexOf(DECRST_BRACKETED_PASTE)
if (handoff === -1) {
return chunk
}
handoffs += 1
scanner = createDraftPasteReadyScanner(signal)
if (quietTimer) {
clearTimeout(quietTimer)
quietTimer = null
}
return window.slice(handoff + DECRST_BRACKETED_PASTE.length)
}
const observe = (chunk: string): void => {
if (settled) {
return
}
const data = isShellInFront ? sinceShellHandoff(chunk) : chunk
const result = scanner.observe(data)
if (result.ready) {
return finish(ptyId)
return onSignal()
}
if (result.armQuietTimer && !options.requireComposerMarker) {
if (quietTimer) {
clearTimeout(quietTimer)
}
quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS)
quietTimer = setTimeout(onSignal, BRACKETED_PASTE_QUIET_MS)
}
}
options.signal?.addEventListener('abort', onAbort)
unsubscribe = host.subscribeToData(ptyId, observe)
hardTimer = setTimeout(
() => finish(null),
+9 -3
View File
@@ -147,12 +147,15 @@ export function nameOnlyIdleNeedsCorroboration(
export function hasSustainedTitleIdle(
record: TuiIdleEvidenceRecord,
agent: TuiAgent | null | undefined,
quiescenceMs: number
quiescenceMs: number,
launchReadiness = false
): boolean {
if (record.lastAgentStatus !== 'idle') {
return false
}
if (!nameOnlyIdleNeedsCorroboration(agent, record.lastOscTitle)) {
// Why launch readiness always corroborates: a shell auto-title (`grok`, `gemini`) names the
// agent before its TUI mounts, and a paste then lands in a booting TUI or the shell itself.
if (!launchReadiness && !nameOnlyIdleNeedsCorroboration(agent, record.lastOscTitle)) {
// The title is the only rest signal this agent emits, so there is nothing to wait for.
return true
}
@@ -201,6 +204,9 @@ export type TuiIdleEvaluationInput = {
agent: TuiAgent | null | undefined
firstPartyStatus: FirstPartyAgentStatus
quiescenceMs: number
/** Waiting for a just-launched agent to open its composer, where a name-only title proves
* nothing until the stream goes quiet. */
launchReadiness?: boolean
}
export type TuiIdleVerdict =
@@ -297,7 +303,7 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict {
if (input.record.lastAgentStatus === 'working') {
return WORKING
}
if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs)) {
if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs, input.launchReadiness)) {
return READY_WEAK
}
return {
@@ -0,0 +1,95 @@
/**
* A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's
* bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name,
* the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still
* booting, or into the shell. A launch readiness wait holds that title to a quiet stream.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
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') }
}))
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
const BOOT_FRAME = 'Loading…\r\n'
/** The shell echoes the launch line, then its preexec hook titles the pane with the command. */
async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') {
return createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: agent,
launchAgent: agent,
data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}`
})
}
/** The agent is still painting its boot screen: output keeps arriving each second. */
async function keepBooting(
runtime: Awaited<ReturnType<typeof launchedPaneTitledByShell>>['runtime'],
seconds: number
): Promise<void> {
for (let second = 0; second < seconds; second += 1) {
runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now())
await vi.advanceTimersByTimeAsync(1000)
}
}
describe('a launch waiting on an agent whose shell titled the pane with its name', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal.
it.each(['gemini', 'copilot'] as const)(
'does not treat the shell’s auto-title as readiness while %s is still booting',
async (agent) => {
const { runtime, handle } = await launchedPaneTitledByShell(agent)
const settled = vi.fn()
runtime
.waitForTerminal(handle, {
condition: 'tui-idle',
timeoutMs: 60_000,
launchReadiness: true
})
.then(settled, settled)
await keepBooting(runtime, 10)
expect(settled).not.toHaveBeenCalled()
}
)
it('settles once the agent’s stream goes quiet under that title', async () => {
const { runtime, handle } = await launchedPaneTitledByShell('gemini')
const settled = vi.fn()
runtime
.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000, launchReadiness: true })
.then(settled, settled)
await keepBooting(runtime, 4)
await vi.advanceTimersByTimeAsync(8_000)
expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true }))
})
it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => {
const { runtime, handle } = await launchedPaneTitledByShell('gemini')
const settled = vi.fn()
runtime
.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 })
.then(settled, settled)
await keepBooting(runtime, 10)
expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true }))
})
})
@@ -28,9 +28,9 @@ describe('ZCode readiness from captured terminal bytes', () => {
launchAgent: 'zcode',
data: '\x1b[?1049h╭'
})
await expect(
runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)
).resolves.toBeUndefined()
await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toMatchObject(
{ condition: 'tui-idle', satisfied: true }
)
})
it('never emits an OSC title, so no title lane can settle its wait', () => {
@@ -3,8 +3,10 @@ import {
markTerminalBracketedPasteInterrupted,
observeTerminalBracketedPasteModeOutput,
pasteTerminalText,
sanitizeTerminalPasteText
sanitizeTerminalPasteText,
wrapTerminalBracketedPasteText
} from './terminal-bracketed-paste'
import { wrapTerminalBracketedPasteText as hostPasteFrame } from '../../../../shared/terminal-bracketed-paste-text'
function createTerminal(bracketedPasteMode = true) {
const terminal = {
@@ -21,6 +23,11 @@ function createTerminal(bracketedPasteMode = true) {
}
describe('terminal bracketed paste policy', () => {
// The host's launch prompt replaced the desktop's draft paste; one function keeps their bytes equal.
it('frames pasted text with the same function as the host launch prompt', () => {
expect(wrapTerminalBracketedPasteText).toBe(hostPasteFrame)
})
it('temporarily ignores bracketed paste wrappers for single-line paste after Ctrl+C', () => {
const terminal = createTerminal(true)
const observedIgnoreValues: (boolean | undefined)[] = []
@@ -1,5 +1,20 @@
import type { Terminal } from '@xterm/xterm'
import type { WindowsInputRecordNewline } from './terminal-paste-model'
import {
BRACKETED_PASTE_END,
BRACKETED_PASTE_START,
normalizeTerminalPasteLineEndings,
sanitizeBracketedPasteText,
wrapTerminalBracketedPasteText
} from '../../../../shared/terminal-bracketed-paste-text'
export {
BRACKETED_PASTE_END,
BRACKETED_PASTE_START,
normalizeTerminalPasteLineEndings,
sanitizeBracketedPasteText,
wrapTerminalBracketedPasteText
}
type BracketedPasteTerminal = {
modes: {
@@ -21,8 +36,6 @@ type PasteTerminalTextOptions = {
const interruptedBracketedPasteTerminals = new WeakSet<object>()
const bracketedPasteModeOutputTail = new WeakMap<object, string>()
const ESCAPE = '\u001b'
export const BRACKETED_PASTE_START = `${ESCAPE}[200~`
export const BRACKETED_PASTE_END = `${ESCAPE}[201~`
const BRACKETED_PASTE_MODE_SEQUENCE_RE = /^\[\?(?:\d+;)*2004(?:;\d+)*[hl]/
const BRACKETED_PASTE_MODE_TAIL_MAX = 128
const BRACKETED_PASTE_MODE_SEQUENCE_SCAN_MAX = BRACKETED_PASTE_MODE_TAIL_MAX
@@ -45,40 +58,10 @@ function hasBracketedPasteModeSequence(data: string): boolean {
return false
}
// Why: an embedded ESC (e.g. a pasted `\x1b[201~` from scrollback) would close
// the bracketed-paste frame early and run the tail as keystrokes. Replacing ESC
// with its printable substitute (\u241b, U+241B) neutralizes every framing escape.
export function sanitizeBracketedPasteText(text: string): string {
let escapeIndex = text.indexOf(ESCAPE)
if (escapeIndex === -1) {
return text
}
let sanitized = ''
let start = 0
while (escapeIndex !== -1) {
sanitized += `${text.slice(start, escapeIndex)}\u241b`
start = escapeIndex + ESCAPE.length
escapeIndex = text.indexOf(ESCAPE, start)
}
return sanitized + text.slice(start)
}
export function sanitizeTerminalPasteText(text: string): string {
return sanitizeBracketedPasteText(text)
}
export function normalizeTerminalPasteLineEndings(text: string): string {
// Why: xterm's native paste path converts every clipboard newline to CR.
// Direct frames must match it or ConPTY TUIs can treat raw LF as submit.
return text.replace(/\r?\n/g, '\r')
}
export function wrapTerminalBracketedPasteText(text: string): string {
const normalizedText = normalizeTerminalPasteLineEndings(text)
return `${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(normalizedText)}${BRACKETED_PASTE_END}`
}
export function encodeWindowsInputRecordPasteText(
text: string,
newline: WindowsInputRecordNewline
+2 -1
View File
@@ -2,6 +2,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { TerminalInputKind } from '../../../shared/terminal-input-kind'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS } from '../../../shared/agent-prompt-injection'
import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout'
import { useAppStore } from '@/store'
import {
@@ -34,7 +35,7 @@ export {
// line-edit shortcuts. Callers choose whether to append Enter after the paste.
export const BRACKETED_PASTE_BEGIN = BRACKETED_PASTE_START
export { BRACKETED_PASTE_END }
export const POST_PASTE_SUBMIT_DELAY_MS = 50
export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS
// Why: "the tab has a PTY" and "the agent's composer accepts input" are separate
// states with separate failure modes, so they get separate budgets. A PTY that
+2
View File
@@ -93,6 +93,8 @@ export type AgentLaunchIntent = {
/** The `tabId:leafId` a terminal launch creates its pane under, for a caller that places its own
* tabs. Not a route input; refused when that pane is already live. */
paneKey?: string
/** How a terminal launched into an existing workspace first shows its tab. Not a route input. */
viewMode?: 'terminal' | 'chat'
/** The caller-minted id of the chat session a structured launch creates. Not a route input;
* refused when that session already exists. */
sessionId?: string
+52
View File
@@ -0,0 +1,52 @@
/**
* What an `agent.launchReplay` refusal proves about the launch, for every client that replays one.
*
* Kept in one place because the three answers call for opposite actions: `unsupported` means nothing
* ran and the client may use its older launch path; `unknown` means the agent may be running and the
* client must not launch again on its own; `failed` means the host refused before starting anything.
*/
import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from './agent-launch-pane-already-live'
import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from './agent-launch-session-already-exists'
export type AgentLaunchReplayRefusal = 'unsupported' | 'unknown' | 'failed'
/** The pane or chat the launch reserved is already held. */
export function isAgentLaunchReservationTakenRefusal(error: { code?: string }): boolean {
return (
error.code === AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE ||
error.code === AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE
)
}
/** An older host rejects the method rather than a field. */
export function isAgentLaunchReplayUnsupportedRefusal(error: { code?: string }): boolean {
return (
error.code === 'method_not_found' ||
error.code === 'forbidden' ||
error.code === 'agent_launch_replay_unsupported'
)
}
export function classifyAgentLaunchReplayRefusal(
error: { code?: string },
replayed: boolean
): AgentLaunchReplayRefusal {
if (isAgentLaunchReplayUnsupportedRefusal(error)) {
// Only a refusal of the first send proves nothing ran; after a replay it may be a replacement
// connection whose capability list hasn't landed, answering for an attempt that did start.
return replayed ? 'unknown' : 'unsupported'
}
if (
error.code === 'agent_session_operation_unknown' ||
error.code === 'agent_session_operation_expired'
) {
return 'unknown'
}
if (isAgentLaunchReservationTakenRefusal(error)) {
// A taken reservation proves nothing started only on the first send; after a replay the pane
// or chat holding it may be this launch's own.
return replayed ? 'unknown' : 'failed'
}
return 'failed'
}
+3
View File
@@ -6,6 +6,9 @@ import { TUI_AGENT_CONFIG } from './tui-agent-config'
export const AGENT_PROMPT_BRACKETED_PASTE_START = '\x1b[200~'
export const AGENT_PROMPT_BRACKETED_PASTE_END = '\x1b[201~'
export const AGENT_PROMPT_SUBMIT = '\r'
/** Why: Claude Code can leave a prompt as editable text when paste-end and Enter arrive in the
* same PTY write, so the desktop's draft paste sends Enter on the next turn, this long after. */
export const AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS = 50
/** Why unknown agents keep the lead: an unidentified Claude still needs it, while known non-Claude
* TUIs get pre-lead bytes because Codex drops typed text that shares the paste's write (STA-8200). */
+3 -1
View File
@@ -148,7 +148,9 @@ export function normalizeTerminalTitle(title: string): string {
if (status === 'working') {
return `${GEMINI_WORKING} Gemini CLI`
}
if (status === 'idle') {
// Why only with the glyph: a bare `gemini` title (a shell auto-title) reads idle by default,
// and stamping Gemini's rest glyph on it turned a name into explicit readiness.
if (status === 'idle' && title.includes(GEMINI_IDLE)) {
return `${GEMINI_IDLE} Gemini CLI`
}
}
+6 -1
View File
@@ -307,6 +307,10 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITY = 'agent.launch.v2' as const
// Optional identity support on agent.launch; mobile replay across replacement hosts requires the new method.
export const AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY = 'agent.launch.replay.v1' as const
// A host that sends a launch prompt its typed startup line cannot carry to a paste after
// readiness; an older host folds any prompt into that line, so clients gate prompted launches on it.
export const AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY = 'agent.launch.prompt-carry.v1' as const
// agent.launchReplay requires the ledger; older replacement hosts must reject the method.
export const AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY =
'agent.launch.replay-required.v1' as const
@@ -445,7 +449,8 @@ export const RUNTIME_CAPABILITIES = [
NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY,
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
@@ -102,6 +102,12 @@ export const AgentLaunchFields = z.object({
)
}, 'Malformed launch pane key')
.optional(),
/**
* How a terminal launch in an existing workspace first shows its tab, so the tab opens as the
* caller would have opened it. Presentation only; a chat and a new workspace's startup terminal
* ignore it, and an older host strips it.
*/
viewMode: z.enum(['terminal', 'chat']).optional(),
/**
* The id of the chat session a structured launch creates, minted by the caller so it knows which
* conversation it started before the reply arrives. Refused when a session with this id already
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import {
hasControlByte,
planStartupWithPromptCandidate,
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
} from './startup-line-prompt-carry'
import type { TuiAgent } from './tui-agent'
import {
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY,
RUNTIME_CAPABILITIES
} from './protocol-version'
function offer(agent: TuiAgent, prompt: string, extra: { cmdOverride?: string } = {}) {
return planStartupWithPromptCandidate(
{
agent,
cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {},
platform: 'darwin'
},
prompt
)
}
/** A prompt that brings the quoted `claude '<prompt>'` line to exactly `bytes`. */
function promptForClaudeLineOf(bytes: number): string {
const base = offer('claude', '').plan?.launchCommand ?? ''
// `<base> '<prompt>'`: one space and two quotes around the text.
return 'x'.repeat(bytes - base.length - 3)
}
describe('whether a launch prompt rides the typed startup line', () => {
it('carries a short single-line prompt on the launch command', () => {
const { plan, promptCarried } = offer('claude', 'explain this repo')
expect(promptCarried).toBe(true)
expect(plan?.launchCommand).toContain('explain this repo')
})
it('carries a line of exactly the budget and refuses one byte past it', () => {
const atBudget = offer('claude', promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES))
expect(new TextEncoder().encode(atBudget.plan?.launchCommand ?? '').byteLength).toBe(
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
)
expect(atBudget.promptCarried).toBe(true)
const pastBudget = offer(
'claude',
promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)
)
expect(pastBudget.promptCarried).toBe(false)
})
it('measures the quoted line, so quote-heavy text under the budget raw can exceed it typed', () => {
// 200 quotes are 200 raw bytes but 600 once portable quoting expands each to `"'"`.
const quotes = "'".repeat(200)
expect(quotes.length).toBeLessThan(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES)
const { plan, promptCarried } = offer('claude', quotes)
expect(promptCarried).toBe(false)
expect(plan?.launchCommand).not.toContain(`"'"`)
})
it.each([
['LF', 'first line\nsecond line'],
['CRLF', 'first line\r\nsecond line'],
['CR', 'first line\rsecond line']
])('never types a %s-bearing prompt, which a shell would read as Enter', (_label, prompt) => {
const { plan, promptCarried } = offer('codex', prompt)
expect(promptCarried).toBe(false)
// The clean launch: the prompt is left for the paste after start.
expect(plan?.launchCommand).not.toContain('first line')
expect(plan?.followupPrompt).toBeNull()
})
it.each([
['TAB', 'see\tthis'],
['ESC', 'red \x1b[31mtext'],
['^C', 'stop\x03here'],
['^U', 'kill\x15line'],
['DEL', 'erase\x7fme']
])(
'never types a %s-bearing prompt, which a line editor would read as a key',
(_label, prompt) => {
const { plan, promptCarried } = offer('claude', prompt)
expect(promptCarried).toBe(false)
expect(hasControlByte(plan?.launchCommand ?? '')).toBe(false)
}
)
it('counts the launcher toward the line, so a long configured one leaves no room for the prompt', () => {
const launcher = `claude ${'--add-dir /very/long/path '.repeat(30)}`.trim()
expect(launcher.length).toBeGreaterThan(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES)
const { plan, promptCarried } = offer('claude', 'hi', { cmdOverride: launcher })
expect(promptCarried).toBe(false)
expect(plan?.launchCommand).toBe(launcher)
})
it('carries a Hermes prompt through its env transport, whose typed line never holds the text', () => {
const multiLine = 'line one\nline two'
const { plan, promptCarried } = offer('hermes', multiLine)
expect(promptCarried).toBe(true)
expect(plan?.launchCommand).not.toContain('line one')
expect(Object.values(plan?.env ?? {})).toContain(multiLine)
})
it('launches Hermes clean instead of refusing when its env budget cannot hold the prompt', () => {
const { plan, promptCarried } = offer('hermes', 'x'.repeat(30_000))
expect(promptCarried).toBe(false)
expect(plan).not.toBeNull()
expect(Object.values(plan?.env ?? {}).join('')).not.toContain('xxxx')
})
it('never carries a stdin-after-start agent’s prompt, whose CLI takes none', () => {
const { plan, promptCarried } = offer('aider', 'fix it')
expect(promptCarried).toBe(false)
expect(plan?.followupPrompt).toBeNull()
})
it('reports nothing carried for an empty prompt', () => {
expect(offer('claude', ' ').promptCarried).toBe(false)
})
})
describe('the capability clients gate a prompted launch on', () => {
it('is advertised by every host that applies the typed-line rule', () => {
// An older host folds any prompt into the typed line, so its absence is the gate.
expect(RUNTIME_CAPABILITIES).toContain(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY)
})
})
+79
View File
@@ -0,0 +1,79 @@
/**
* Whether a launch prompt rides the command line that gets TYPED into the user's shell, or the agent
* starts clean and the prompt is pasted once it is ready.
*
* Measured on the built line, not the raw prompt: quoting, the launcher, its arguments and session
* options all land on that line, and every failure a long or multi-line typed line has is a property
* of the line — macOS bash 3.2 reads each newline as Enter, a line editor reads any other control
* byte as a key, a canonical-mode write truncates a line
* past MAX_CANON (1024 on macOS), and cmd caps a line at 8191. Decided here, where the line exists,
* so the answer is a fact about what was built rather than a prediction of it.
*/
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startup'
import type { TuiAgent } from './tui-agent'
/** Half of macOS MAX_CANON: a single typed line this long survived every canonical-mode write
* measured, and 1 KiB did not. */
export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512
const encoder = new TextEncoder()
function typedLineBytes(line: string): number {
return encoder.encode(line).byteLength
}
export function startupLineCarriesPrompt(args: {
agent: TuiAgent
withPrompt: AgentStartupPlan | null
}): boolean {
const { withPrompt } = args
if (!withPrompt || withPrompt.followupPrompt !== null) {
return false
}
// Hermes types a fixed line that reads the prompt from the spawn env, so the line never grows
// with the text; its own env budget already returned null above when the text did not fit.
if (TUI_AGENT_CONFIG[args.agent].promptInjectionMode === 'hermes-query') {
return true
}
const line = withPrompt.launchCommand
return !hasControlByte(line) && typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
}
/** Any C0 byte or DEL, not just CR/LF: no quoter escapes them, and a single-line command is written
* raw, so a TAB completes, ESC starts a key sequence, and ^C/^U/^W kill or edit the line. */
export function hasControlByte(line: string): boolean {
for (let i = 0; i < line.length; i += 1) {
const code = line.charCodeAt(i)
if (code < 0x20 || code === 0x7f) {
return true
}
}
return false
}
type StartupPlanInputs = Omit<
Parameters<typeof buildAgentStartupPlan>[0],
'prompt' | 'allowEmptyPromptLaunch'
>
/**
* The startup plan for a launch that offers a prompt: the prompted plan when its typed line can
* carry the text, else the clean plan, with which one it was.
*/
export function planStartupWithPromptCandidate(
inputs: StartupPlanInputs,
prompt: string
): { plan: AgentStartupPlan | null; promptCarried: boolean } {
if (prompt.trim()) {
const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true })
if (startupLineCarriesPrompt({ agent: inputs.agent, withPrompt })) {
return { plan: withPrompt, promptCarried: true }
}
}
return {
plan: buildAgentStartupPlan({ ...inputs, prompt: '', allowEmptyPromptLaunch: true }),
promptCarried: false
}
}
+1
View File
@@ -141,6 +141,7 @@ export const launchSourceSchema = z.enum([
'conflict_resolution',
'source_control_recovery',
'terminal_context_menu',
'explain_commit',
// Launches the host performs for a caller outside the desktop app.
'cli',
'mobile',
@@ -0,0 +1,39 @@
/**
* The bracketed-paste frame Orca writes for pasted text: the renderer's terminal paste and draft
* paste, and the host's launch prompt, which replaced the desktop's draft paste and must send the
* same bytes.
*/
const ESCAPE = '\u001b'
export const BRACKETED_PASTE_START = `${ESCAPE}[200~`
export const BRACKETED_PASTE_END = `${ESCAPE}[201~`
// Why: an embedded ESC (e.g. a pasted `\x1b[201~` from scrollback) would close
// the bracketed-paste frame early and run the tail as keystrokes. Replacing ESC
// with its printable substitute (␛, U+241B) neutralizes every framing escape.
export function sanitizeBracketedPasteText(text: string): string {
let escapeIndex = text.indexOf(ESCAPE)
if (escapeIndex === -1) {
return text
}
let sanitized = ''
let start = 0
while (escapeIndex !== -1) {
sanitized += `${text.slice(start, escapeIndex)}␛`
start = escapeIndex + ESCAPE.length
escapeIndex = text.indexOf(ESCAPE, start)
}
return sanitized + text.slice(start)
}
export function normalizeTerminalPasteLineEndings(text: string): string {
// Why: xterm's native paste path converts every clipboard newline to CR.
// Direct frames must match it or ConPTY TUIs can treat raw LF as submit.
return text.replace(/\r?\n/g, '\r')
}
export function wrapTerminalBracketedPasteText(text: string): string {
const normalizedText = normalizeTerminalPasteLineEndings(text)
return `${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(normalizedText)}${BRACKETED_PASTE_END}`
}
+4 -1
View File
@@ -247,7 +247,10 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
'qwen-code': {
// Why: package is qwen-code but its installed CLI binary on PATH is `qwen`.
detectCmd: 'qwen',
promptInjectionMode: 'stdin-after-start'
promptInjectionMode: 'stdin-after-start',
// Why: on Windows (and Node < 20) Qwen ignores Enter until 500 ms after a paste (0.24.7
// `pasteWorkaround`), so the Enter 50 ms after it is dropped; on an empty composer it is a no-op.
submitRetryDelayMs: 1200
},
rovo: {
detectCmd: 'rovo',