mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
feat(agent-launch): keep long prompts off the launch line and paste them after readiness (step 1 of 7) (#24257)
* 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. * fix(agent-launch): keep a short-lined multi-line prompt on a local zsh launch line, as main did #24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste. The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early. * test(agent-launch): keep the real-zsh launch-line test out of the Windows lane's gate scan The Windows lane registration check read `const ZSH_PATH = process.platform === 'win32'` (the head of a multi-line ternary) as a Windows-true flag, so `describe.skipIf(!ZSH_PATH)` looked like a Windows-only suite. The file is POSIX-only; the zsh lookup is now a function. * refactor(protocol): move the agent.launch capabilities into their own module Main's protocol-version.ts sits at the 300-line cap, so the prompt-carry capability pushed it over. The four agent.launch capabilities and their doc move to agent-launch-runtime-capability.ts, re-exported by name and spread into RUNTIME_CAPABILITIES at the same position; the advertised lists and every export are unchanged. * refactor(protocol): import the agent.launch capabilities from their own module `export *` from protocol-version left the four names undefined under the mobile recording loader, which resolves a relative import through a Proxy with no own keys, so 37 phone recordings lost agent.launchReplay. Importers now name agent-launch-runtime-capability directly; protocol-version only spreads its list. * refactor(agent-launch): drop the unshipped viewMode field and trusted local caller id Both were inert in step 1 and existed only for step 2. agent.launch will become a public plugin API, so every wire field is permanent once shipped; a top-level viewMode reads as "choose terminal vs chat", which the host decides. Step 2 introduces placement and view intent under a placement object instead. * fix(agent-launch): read Codex's provisional startup from the rule files' hold anchor Main (#24375) moved Codex's provisional-header check into codex.json's provisional_startup hold anchor and deleted codex-terminal-readiness.ts, so the launch readiness hold now asks showsHoldAnchor, as main's own settled check does. * fix(agent-launch): hold rule-file name titles to quiet for a launch, and census the zsh fixture Main (#24375) answers a name-only title from each agent's rule file ahead of the sustained-title lane, so gemini.json's name_title settled a launch readiness wait on the shell's auto-title while Gemini was still booting. A launch now asks quiet of every weak idle verdict, as that lane did. Main's readiness census requires a recorder for every runtime fixture; the zsh prompt recording is a non-agent control. Gemini's synthetic baseline is regenerated for this PR's stated change: a bare gemini title is no longer its rest mark, so name-only rows settle weak, and a fresh working or blocked status is no longer overridden. * fix(agent-launch): paste a launch prompt only when the launched agent is proven in front A launch pasted its prompt unless a shell was proven in the terminal's foreground, so any read that could not prove one let the prompt through. After an agent exited at startup, its shell turned bracketed paste on at the next prompt, readiness fired on it, and the prompt was typed into the shell: - macOS: a pane runs its shell under login, so the process-group fence's root was never the shell's group and never proved it; the cached foreground name could also still name the exited process. - Windows Git Bash and WSL: the shell-alone-in-its-job check never answers. Now one fresh read of the terminal's foreground decides: agent, shell or unknown. Only 'agent' lets a write through (paste, Enter, second Enter, reused panes too); 'shell' still drops a ready signal. A Windows host never proves the agent, so there the launch line carries the prompt at any size, as on main. * test(agent-launch): cover the Windows QA stub, a grok override that exits at once * fix(agent-launch): keep the local socket alive while a prompted launch waits for its agent A launch with a prompt now waits up to 60 s for the terminal agent to be ready before it writes the prompt, and reports not-delivered when the agent never is. The local runtime socket closes a connection idle for 30 s unless the request is a long poll, so a launch whose agent exited at startup lost its reply and the caller saw 'runtime closed the connection' instead of not-delivered. Classify a prompted agent.launch and agent.launchReplay as a long poll, as orchestration.workerStart already is for the same wait. * refactor(agent-launch): narrow the launch params by 'in' instead of a cast * fix(agent-launch): find a launched agent behind a wrapper that leads its process group A tcsh or nu launch line runs the agent from /bin/sh '<script>', and a wrapper script that does not exec its agent does the same: the wrapper leads the terminal's foreground process group and the agent is a member of it. The fresh foreground read names the group's leader, sh, so a prompted launch was refused or pasted late (M4Air tcsh: 2 of 4 not delivered, 2 pasted ~9 s late). Before that read, take the host's process-group observation as positive proof when it names the launched agent among the foreground group's members and is younger than a ready signal's quiet window. It never proves a shell. * fix(agent-launch): judge the foreground-group proof by when its capture began, not how long ps took The age the host stamps on a process-group observation runs from the start of its whole-machine ps, so on a loaded Mac a capture begun after the read was asked for still read as older than 1 s and the proof was dropped. Count an observation whose capture began after the read was asked for, less the window a shared capture is reused across. * test(agent-launch): keep the crash-guard live test out of the Windows lane's gate scan The Windows-lane registration scan read the const assigned from a platform check as a Windows-only gate, though the suite runs everywhere but Windows; find zsh in a function instead, as the real-zsh typed-line test does. Under load the fresh foreground scan can fail to answer, which lets the shell's prompt settle readiness (2 of 4 paired runs). The guard still refuses that write, so assert the refused write, the property that must always hold. * perf(agent-launch): read a local pane's foreground from its own terminal, not the whole process table The foreground read that gates every launch paste ran the daemon's inspectProcess capture and then a fresh scan, each a whole-machine ps; the fresh one also waits for any capture already running before it starts its own. Measured here at load 5: 1.2 s a read (M4Air QA: 3.4-5.0 s, and worker starts 17.6-32 s against main's 9-12 s at load 25-84). On a local macOS or Linux host, take the pane's root pid from the provider's session inventory and run one ps limited to that pane's terminal. Its foreground process group decides: the launched agent or any non-shell member is the agent (a wrapper that did not exec its agent leads the group), a group of shells alone is the shell. Same pane, same verdict: 2.7 ms a read. SSH hosts keep the relay's observation and name. * test(mobile): re-measure the web app's script sweep after agent.launch's capabilities moved out of protocol-version The mobile web bundle check failed at 124 assets against a ceiling of 123. Main already sat exactly on that ceiling: its sweep table read 69 scripts at 16 routes while the tree builds 73, the whole margin of 4. This branch imports the agent.launch capabilities from their own module, so protocol-version is no longer pulled into the root layout and four other routes. That moves which routes share which modules, and the Qoder capability module, imported by protocol-version and the AI-vault resume path, no longer shares an importer set with anything, so it gets a chunk of its own: 74 scripts. The fence says to re-derive the bound rather than raise it, so the sweep is re-measured on this head (every prefix of the sorted route list). The worst route now adds 10 scripts (session), not 9, which moves the pinned shell crossing from 32 to 30 routes; main re-measured on its own lands on the same crossing. * fix(agent-launch): a worker's brief needs its agent found in front, and Grok's start answers on its composer A paired-server worker start whose agent exited at startup typed its brief into the server's shell, which ran it: the idle wait can settle on a shell back at its prompt, and the brief was written with no foreground read. Both worker-start paths now check before each brief write, as a launch prompt is checked: on a host that can find the agent in front it must be there; on one that cannot (Windows) a shell proven in front still refuses, and anything else writes as before. A Grok worker start waited ~10 s more than main: its only rest signal is its bare name, which a launch holds to quiet output, and Grok animates its logo for ten seconds after its composer glyph. A worker start for an agent whose rest signal is its bare name and whose composer draws a marker (Grok, DSH, mimo-code) now also answers on that marker, whichever comes first.
This commit is contained in:
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Split out of protocol-version.ts, which spreads the list below into RUNTIME_CAPABILITIES in this
|
||||
// order. Import these names from here: the mobile recording loader cannot follow `export *`.
|
||||
|
||||
/**
|
||||
* `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the
|
||||
* surface, instead of each client routing for itself.
|
||||
*
|
||||
* Negotiated rather than assumed because a client that cannot see it must keep using
|
||||
* `worktree.create` + `startupAgent`, which stays supported verbatim. The reverse skew is the
|
||||
* dangerous one: `worktree.create` returns `agentTerminalHandle` only when a startup agent was
|
||||
* requested, so a host that quietly routed that call to a structured session would hand an old
|
||||
* client a response with no handle and no error.
|
||||
*
|
||||
* Advertising it is a statement that the client understands EITHER outcome, since the host is what
|
||||
* picks: a structured session it can open, or a terminal agent. A client that renders only one of
|
||||
* the two keeps using the surface-specific methods.
|
||||
*/
|
||||
// v2 makes prompt delivery an outcome union and top-level warnings the only supported shape.
|
||||
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
|
||||
|
||||
export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
@@ -6,6 +6,9 @@ import { isTuiAgent, 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). */
|
||||
|
||||
@@ -153,7 +153,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`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,11 @@ const DRAFT_PASTE_READY_SIGNALS: Record<DraftPasteReadySignal, DraftPasteReadySi
|
||||
/** Longest anchor sequence minus one — the carry needed to rejoin one split across chunks. */
|
||||
const ANCHOR_CARRY_CHARS = 7
|
||||
|
||||
/** Whether the signal has a composer marker, rather than only a quiet window after its anchor. */
|
||||
export function draftPasteReadySignalHasMarker(readySignal: DraftPasteReadySignal): boolean {
|
||||
return DRAFT_PASTE_READY_SIGNALS[readySignal].marker !== null
|
||||
}
|
||||
|
||||
export type DraftPasteReadyScanResult = {
|
||||
/** The agent-specific ready signal fired — caller should deliver the paste now. */
|
||||
ready: boolean
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
SKILL_UPLOAD_CAPABILITY
|
||||
} from './skill-install-capability'
|
||||
export { SKILL_INSTALL_RESULT_V2_CAPABILITY } from './skill-install-capability'
|
||||
import {
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITIES,
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY
|
||||
} from './agent-launch-runtime-capability'
|
||||
|
||||
// Why: declares the Orca runtime RPC compatibility contract. Desktop,
|
||||
// headless server, CLI, and mobile builds may drift in app version, but
|
||||
@@ -309,30 +313,6 @@ export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
|
||||
// Hosts without this capability have no notifications.registerPush RPC.
|
||||
export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const
|
||||
|
||||
/**
|
||||
* `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the
|
||||
* surface, instead of each client routing for itself.
|
||||
*
|
||||
* Negotiated rather than assumed because a client that cannot see it must keep using
|
||||
* `worktree.create` + `startupAgent`, which stays supported verbatim. The reverse skew is the
|
||||
* dangerous one: `worktree.create` returns `agentTerminalHandle` only when a startup agent was
|
||||
* requested, so a host that quietly routed that call to a structured session would hand an old
|
||||
* client a response with no handle and no error.
|
||||
*
|
||||
* Advertising it is a statement that the client understands EITHER outcome, since the host is what
|
||||
* picks: a structured session it can open, or a terminal agent. A client that renders only one of
|
||||
* the two keeps using the surface-specific methods.
|
||||
*/
|
||||
// v2 makes prompt delivery an outcome union and top-level warnings the only supported shape.
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
// Generic native clients include the CLI and must not claim Electron-only page
|
||||
// placement support.
|
||||
export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
|
||||
@@ -452,9 +432,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY
|
||||
...AGENT_LAUNCH_RUNTIME_CAPABILITIES
|
||||
] as const
|
||||
|
||||
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasControlByte,
|
||||
planStartupWithPromptCandidate,
|
||||
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES,
|
||||
ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
|
||||
} from './startup-line-prompt-carry'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import { RUNTIME_CAPABILITIES } from './protocol-version'
|
||||
import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability'
|
||||
|
||||
function offer(
|
||||
agent: TuiAgent,
|
||||
prompt: string,
|
||||
extra: {
|
||||
cmdOverride?: string
|
||||
shellName?: string | undefined
|
||||
platform?: NodeJS.Platform
|
||||
provesAgentInFront?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return planStartupWithPromptCandidate(
|
||||
{
|
||||
agent,
|
||||
cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {},
|
||||
platform: extra.platform ?? 'darwin'
|
||||
},
|
||||
prompt,
|
||||
{
|
||||
...(extra.shellName ? { shellName: extra.shellName } : {}),
|
||||
provesAgentInFront: extra.provesAgentInFront ?? true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** `count` prompt lines of `bytes` each, as a multi-line source-control prompt is. */
|
||||
function linesOf(count: number, bytes: number): string {
|
||||
return Array.from({ length: count }, (_unused, index) => `${index}:`.padEnd(bytes, 'x')).join(
|
||||
'\n'
|
||||
)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
})
|
||||
})
|
||||
|
||||
// Pinned live by startup-line-typed-length.live-shell.test.ts; the refusals below were measured on
|
||||
// macOS through the same write: a 1.1 KB single line lost to zsh's late write, every multi-line line
|
||||
// lost to bash 3.2, and to fish whenever its config outlasted the ready barrier.
|
||||
describe('a multi-line prompt typed into a shell the host names', () => {
|
||||
it('rides a zsh line as main typed it when each line is short, so the agent starts with it', () => {
|
||||
const prompt = linesOf(21, 100)
|
||||
const { plan, promptCarried } = offer('claude', prompt, { shellName: 'zsh' })
|
||||
expect(promptCarried).toBe(true)
|
||||
expect(plan?.launchCommand).toContain(prompt)
|
||||
})
|
||||
|
||||
it('keeps a zsh line off the launch command when any one line exceeds the per-line budget', () => {
|
||||
const prompt = `${linesOf(3, 100)}\n${'y'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)}`
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a zsh line off the launch command past the whole-line budget', () => {
|
||||
const prompt = linesOf(Math.ceil(ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES / 400) + 1, 400)
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a long single line off a zsh launch command, which a late write truncates', () => {
|
||||
const prompt = promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it.each([['bash'], ['fish'], ['pwsh'], [undefined]])(
|
||||
'never types a multi-line line into %s',
|
||||
(shellName) => {
|
||||
const { plan, promptCarried } = offer('claude', linesOf(3, 50), { shellName })
|
||||
expect(promptCarried).toBe(false)
|
||||
expect(plan?.launchCommand).not.toContain('0:')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['TAB', 'see\tthis\nnext line'],
|
||||
['CR', 'first\r\nsecond']
|
||||
])('never types a %s-bearing multi-line prompt into zsh', (_label, prompt) => {
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a host that cannot prove the agent took the terminal could paste into the shell of one that
|
||||
// exited, so there the line carries the prompt whatever its size, as it did before.
|
||||
describe('on a host that cannot prove the launched agent is in front', () => {
|
||||
it.each([
|
||||
['a long single line', 'x'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES * 4)],
|
||||
['a multi-line prompt', linesOf(5, 40)]
|
||||
])('carries %s on the launch line', (_label, prompt) => {
|
||||
const { plan, promptCarried } = offer('claude', prompt, {
|
||||
platform: 'win32',
|
||||
provesAgentInFront: false
|
||||
})
|
||||
expect(promptCarried).toBe(true)
|
||||
expect(plan?.launchCommand).toContain(prompt.split('\n')[0])
|
||||
// Control: the same prompt is pasted where the host can prove the agent.
|
||||
expect(offer('claude', prompt, { platform: 'win32' }).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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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. A paste needs a host that can prove the
|
||||
* agent holds its terminal (`launched-agent-foreground`); elsewhere the line carries it, as on main.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* `startup-line-typed-length.live-shell.test.ts` types lines through Orca's own ready barrier and
|
||||
* submission into real shells, including one whose config outlasts the barrier's 1.5 s cap so the
|
||||
* line lands while the terminal is still line-buffered. Only zsh read a multi-line line whole in
|
||||
* both cases, and only while each of its lines stayed short; a long single line was lost there.
|
||||
*/
|
||||
|
||||
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. Also the cap on each line of a multi-line one. */
|
||||
export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512
|
||||
|
||||
/** A multi-line line typed into zsh: the largest measured intact behind a late write (8 KiB). */
|
||||
export const ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES = 8192
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function typedLineBytes(line: string): number {
|
||||
return encoder.encode(line).byteLength
|
||||
}
|
||||
|
||||
export function startupLineCarriesPrompt(args: {
|
||||
agent: TuiAgent
|
||||
withPrompt: AgentStartupPlan | null
|
||||
/** The shell the line is typed into, when the host can name it before the spawn. */
|
||||
shellName?: string
|
||||
/** Whether the host can prove the launched agent holds its terminal before it pastes. */
|
||||
hostProvesAgentInFront: boolean
|
||||
}): boolean {
|
||||
const { withPrompt } = args
|
||||
if (!withPrompt || withPrompt.followupPrompt !== null) {
|
||||
return false
|
||||
}
|
||||
// Where nothing can prove the agent took the terminal, a paste could land in the shell of an
|
||||
// agent that exited, so the line carries the prompt at any size, as it always did.
|
||||
if (!args.hostProvesAgentInFront) {
|
||||
return true
|
||||
}
|
||||
// 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
|
||||
if (!hasControlByte(line)) {
|
||||
return typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
|
||||
}
|
||||
return args.shellName === 'zsh' && zshReadsMultiLineWhole(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* zsh takes a multi-line line as one bracketed paste, and a write that beats its line editor still
|
||||
* reaches it line by line, so each line must fit the terminal's line buffer. bash 3.2 has no
|
||||
* bracketed paste, and fish drops a paste written before its reader is up.
|
||||
*/
|
||||
function zshReadsMultiLineWhole(line: string): boolean {
|
||||
const lines = line.split('\n')
|
||||
return (
|
||||
lines.every(
|
||||
(part) =>
|
||||
!hasControlByte(part) && typedLineBytes(part) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
|
||||
) && typedLineBytes(line) <= ZSH_MULTI_LINE_STARTUP_LINE_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,
|
||||
host: { shellName?: string; provesAgentInFront: boolean }
|
||||
): { plan: AgentStartupPlan | null; promptCarried: boolean } {
|
||||
if (prompt.trim()) {
|
||||
const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true })
|
||||
if (
|
||||
startupLineCarriesPrompt({
|
||||
agent: inputs.agent,
|
||||
withPrompt,
|
||||
...(host.shellName ? { shellName: host.shellName } : {}),
|
||||
hostProvesAgentInFront: host.provesAgentInFront
|
||||
})
|
||||
) {
|
||||
return { plan: withPrompt, promptCarried: true }
|
||||
}
|
||||
}
|
||||
return {
|
||||
plan: buildAgentStartupPlan({ ...inputs, prompt: '', allowEmptyPromptLaunch: true }),
|
||||
promptCarried: false
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,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}`
|
||||
}
|
||||
@@ -261,7 +261,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',
|
||||
|
||||
Reference in New Issue
Block a user