fix: speed up Codex draft prefill (#2105)

This commit is contained in:
Neil 2026-05-16 13:58:17 -07:00 committed by GitHub
parent 087f1aa9bf
commit 312677132e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 474 additions and 51 deletions

View File

@ -25,7 +25,7 @@ vi.mock('node:os', async () => {
}
})
const { markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
await import('./agent-trust-presets')
beforeEach(() => {
@ -111,3 +111,55 @@ describe('markCopilotFolderTrusted', () => {
}
})
})
describe('markCodexProjectTrusted', () => {
it('writes ~/.codex/config.toml with the project marked trusted', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
try {
const realpath = realpathSync(workspace)
markCodexProjectTrusted(workspace)
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
expect(existsSync(configPath)).toBe(true)
const written = readFileSync(configPath, 'utf-8')
expect(written).toContain(`[projects."${escapeTomlBasicString(realpath)}"]`)
expect(written).toContain('trust_level = "trusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
it('preserves existing config keys and updates an existing project block', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
const realpath = realpathSync(workspace)
try {
const codexDir = join(testState.fakeHomeDir, '.codex')
mkdirSync(codexDir, { recursive: true })
writeFileSync(
join(codexDir, 'config.toml'),
[
'model = "gpt-5.5"',
'',
`[projects."${escapeTomlBasicString(realpath)}"]`,
'notes = "keep"',
'trust_level = "untrusted"',
''
].join('\n'),
'utf-8'
)
markCodexProjectTrusted(workspace)
const written = readFileSync(join(codexDir, 'config.toml'), 'utf-8')
expect(written).toContain('model = "gpt-5.5"')
expect(written).toContain('notes = "keep"')
expect(written).toContain('trust_level = "trusted"')
expect(written).not.toContain('trust_level = "untrusted"')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
})
function escapeTomlBasicString(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
}

View File

@ -2,10 +2,12 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomically } from './codex-accounts/fs-utils'
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
/**
* Pre-mark a workspace as trusted for cursor-agent / GitHub Copilot CLI so
* the agent's "Do you trust this folder?" menu does not fire on first launch.
* Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or
* Codex so the agent's "Do you trust this folder?" menu does not fire on
* first launch.
*
* Why: Orca's "drop URL into agent input as a draft" flow injects the URL
* via bracketed-paste once the TUI is up. If the trust menu intercepts the
@ -18,6 +20,8 @@ import { writeFileAtomically } from './codex-accounts/fs-utils'
* Side note: a `--trust`-style CLI flag exists in cursor-agent but only
* applies in `--print/headless` mode (per its --help). Copilot has no
* documented flag at all (verified against @github/copilot 1.0.32 bundle).
* Codex's `--dangerously-bypass-approvals-and-sandbox` would also change
* approval/sandbox policy, so it is not equivalent to "trust this project".
*/
/**
@ -93,6 +97,20 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
/**
* Codex stores project trust in ~/.codex/config.toml under:
* [projects."<realpath>"]
* trust_level = "trusted"
*
* Verified against codex-rs/tui/src/onboarding/trust_directory.rs and
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
*/
export function markCodexProjectTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const configPath = join(homedir(), '.codex', 'config.toml')
upsertProjectTrustLevel(configPath, absPath, 'trusted')
}
function canonicalize(p: string): string {
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but
// both Cursor and Copilot's trust comparators run realpath() before the

View File

@ -10,6 +10,8 @@ import {
readHookTrustEntries,
removeHookTrustEntries,
upsertHookTrustEntries,
upsertProjectTrustLevel,
upsertProjectTrustLevelInContent,
type CodexTrustEntry
} from './config-toml-trust'
@ -633,6 +635,76 @@ describe('upsertHookTrustEntries', () => {
})
})
describe('upsertProjectTrustLevel', () => {
it('creates a projects trust block when the config is empty', () => {
expect(upsertProjectTrustLevelInContent('', '/tmp/codex-ws', 'trusted')).toBe(
['[projects."/tmp/codex-ws"]', 'trust_level = "trusted"', ''].join('\n')
)
})
it('updates an existing project block without touching unrelated keys', () => {
const original = [
'model = "gpt-5.5"',
'',
'[projects."/tmp/codex-ws"]',
'notes = "keep"',
'trust_level = "untrusted"',
'',
'[profiles.default]',
'sandbox_mode = "workspace-write"',
''
].join('\n')
const updated = upsertProjectTrustLevelInContent(original, '/tmp/codex-ws', 'trusted')
expect(updated).toContain('model = "gpt-5.5"')
expect(updated).toContain('[projects."/tmp/codex-ws"]\nnotes = "keep"')
expect(updated).toContain('trust_level = "trusted"')
expect(updated).not.toContain('trust_level = "untrusted"')
expect(updated).toContain('[profiles.default]\nsandbox_mode = "workspace-write"')
})
it('adds trust_level to an existing project block that does not have one', () => {
const original = [
'[projects."/tmp/codex-ws"]',
'notes = "keep"',
'',
'[other]',
'value = 1',
''
].join('\n')
const updated = upsertProjectTrustLevelInContent(original, '/tmp/codex-ws', 'trusted')
expect(updated).toContain(
['[projects."/tmp/codex-ws"]', 'trust_level = "trusted"', 'notes = "keep"'].join('\n')
)
expect(updated).toContain('[other]\nvalue = 1')
})
it('preserves CRLF endings and escapes the project path in the header', () => {
const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n')
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
expect(updated).toContain(
['[projects."C:\\\\Users\\\\nw\\\\repo"]', 'trust_level = "trusted"', ''].join('\r\n')
)
expect(updated).toContain('[profiles.default]\r\nmodel = "gpt-5"')
})
it('writes config.toml and avoids rewriting an already-trusted project', () => {
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
const firstWrite = readFileSync(configPath, 'utf-8')
rmSync(`${configPath}.bak`, { force: true })
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
expect(readFileSync(configPath, 'utf-8')).toBe(firstWrite)
expect(existsSync(`${configPath}.bak`)).toBe(false)
})
})
describe('removeHookTrustEntries', () => {
it('is a no-op (creates no file) when the config does not exist', () => {
removeHookTrustEntries(configPath, ['/x/hooks.json:pre_tool_use:0:0'])

View File

@ -56,6 +56,8 @@ export type CodexHookTrustState = {
enabled?: boolean
}
export type CodexProjectTrustLevel = 'trusted' | 'untrusted'
// Why: matches Codex's canonical_json. Sorts object keys recursively before
// SHA-256ing; arrays preserve order.
function canonicalize(value: unknown): unknown {
@ -210,6 +212,61 @@ export function upsertHookTrustEntriesInContent(
return updated
}
export function upsertProjectTrustLevel(
configPath: string,
projectPath: string,
trustLevel: CodexProjectTrustLevel
): void {
const existing = existsSync(configPath) ? readTomlFile(configPath) : ''
const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel)
if (updated === existing) {
return
}
writeConfigAtomically(configPath, updated)
}
export function upsertProjectTrustLevelInContent(
existingContent: string,
projectPath: string,
trustLevel: CodexProjectTrustLevel
): string {
const existing =
existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent
const headerPattern = buildProjectHeaderPattern(projectPath)
const match = headerPattern.exec(existing)
const eol = existing.includes('\r\n') ? '\r\n' : '\n'
const trustLine = `trust_level = "${trustLevel}"`
if (!match) {
const block = [`[projects."${escapeTomlString(projectPath)}"]`, trustLine].join(eol)
if (existing.length === 0) {
return `${block}${eol}`
}
const separator = existing.endsWith(`${eol}${eol}`)
? ''
: existing.endsWith(eol)
? eol
: eol + eol
return `${existing}${separator}${block}${eol}`
}
const headerLineEnd = match.index + match[0].length
const after = existing.slice(headerLineEnd)
const nextHeaderRel = findNextTableHeader(after)
const blockEnd = nextHeaderRel === -1 ? existing.length : headerLineEnd + nextHeaderRel
const existingBlock = existing.slice(headerLineEnd, blockEnd)
const trustLevelLinePattern =
/^[ \t]*trust_level[ \t]*=[ \t]*(?:"(?:trusted|untrusted)"|'(?:trusted|untrusted)')[ \t\r]*(?:#.*)?$/m
if (trustLevelLinePattern.test(existingBlock)) {
return (
existing.slice(0, headerLineEnd) +
existingBlock.replace(trustLevelLinePattern, trustLine) +
existing.slice(blockEnd)
)
}
return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}`
}
// Why: build the canonical block we own. The two field names mirror what
// Codex itself writes when the user approves via /hooks (HookStateToml
// fields). `enabled` is plumbed through so an existing user-set
@ -289,6 +346,13 @@ function buildHeaderPattern(key: string): RegExp {
)
}
function buildProjectHeaderPattern(projectPath: string): RegExp {
const escapedPath = escapeRegex(escapeTomlString(projectPath))
return new RegExp(
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
)
}
function escapeRegex(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View File

@ -1,11 +1,15 @@
import { ipcMain } from 'electron'
import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets'
import {
markCodexProjectTrusted,
markCopilotFolderTrusted,
markCursorWorkspaceTrusted
} from '../agent-trust-presets'
export type AgentTrustPreset = 'cursor' | 'copilot'
export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex'
/**
* Why: cursor-agent and GitHub Copilot CLI gate first-launch in an unfamiliar
* directory behind a "Do you trust this folder?" menu that consumes
* Why: cursor-agent, GitHub Copilot CLI, and Codex gate first-launch in an
* unfamiliar directory behind a "Do you trust this folder?" menu that consumes
* keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL
* paste flow needs the input box, not the menu, so before Orca spawns the
* agent it asks main to write the same trust artifacts the agents write
@ -25,6 +29,8 @@ export function registerAgentTrustHandlers(): void {
markCursorWorkspaceTrusted(args.workspacePath)
} else if (args.preset === 'copilot') {
markCopilotFolderTrusted(args.workspacePath)
} else if (args.preset === 'codex') {
markCodexProjectTrusted(args.workspacePath)
}
} catch {
// Best-effort: see Why above. The user can still accept the trust

View File

@ -1118,7 +1118,10 @@ export type PreloadApi = {
hermesStatus: () => Promise<AgentHookInstallStatus>
}
agentTrust: {
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise<void>
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex'
workspacePath: string
}) => Promise<void>
}
preflight: PreflightApi
notifications: {

View File

@ -1149,8 +1149,10 @@ const api = {
},
agentTrust: {
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise<void> =>
ipcRenderer.invoke('agentTrust:markTrusted', args)
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex'
workspacePath: string
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
},
preflight: {

View File

@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { pasteDraftWhenAgentReady } from './agent-paste-draft'
const testState = vi.hoisted(() => ({
appState: {
settings: {},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
},
ptyObserver: null as ((data: string) => void) | null,
unsubscribe: vi.fn(),
subscribeToPtyData: vi.fn(),
isRemoteRuntimePtyId: vi.fn(),
sendRuntimePtyInput: vi.fn(),
subscribeToRuntimeTerminalData: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => testState.appState
}
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
subscribeToPtyData: testState.subscribeToPtyData
}))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId,
sendRuntimePtyInput: testState.sendRuntimePtyInput
}))
vi.mock('@/runtime/runtime-terminal-stream', () => ({
subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData
}))
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m\x1b[0m Ask Codex to do anything'
const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123'
const PASTED_ISSUE_URL = `\x1b[200~${ISSUE_URL}\x1b[201~`
describe('pasteDraftWhenAgentReady', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout
})
testState.appState.settings = {}
testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-1'] }
testState.ptyObserver = null
testState.unsubscribe.mockReset()
testState.subscribeToPtyData.mockReset()
testState.subscribeToPtyData.mockImplementation(
(_ptyId: string, observer: (data: string) => void) => {
testState.ptyObserver = observer
return testState.unsubscribe
}
)
testState.isRemoteRuntimePtyId.mockReset()
testState.isRemoteRuntimePtyId.mockReturnValue(false)
testState.sendRuntimePtyInput.mockReset()
testState.subscribeToRuntimeTerminalData.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('pastes into Codex as soon as its composer prompt renders after bracketed paste is enabled', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex'
})
await flushMicrotasks()
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
await flushMicrotasks()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await flushMicrotasks()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
expect(vi.getTimerCount()).toBe(0)
})
it('detects the Codex composer prompt inside a large first render chunk', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'codex'
})
await flushMicrotasks()
testState.ptyObserver?.(
`${DECSET_BRACKETED_PASTE}${CODEX_COMPOSER_PROMPT_RENDER}${'x'.repeat(900)}`
)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
})
it('keeps the render-quiet wait for agents without the Codex ready signal', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'opencode'
})
await flushMicrotasks()
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await flushMicrotasks()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1499)
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInput).toHaveBeenCalledWith({}, 'pty-1', PASTED_ISSUE_URL)
})
it('does not paste for agents that already use native draft prefill', async () => {
await expect(
pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'pi'
})
).resolves.toBe(false)
expect(testState.subscribeToPtyData).not.toHaveBeenCalled()
expect(testState.sendRuntimePtyInput).not.toHaveBeenCalled()
})
})
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}

View File

@ -1,5 +1,5 @@
import type { TuiAgent } from '../../../shared/types'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { TUI_AGENT_CONFIG, type DraftPasteReadySignal } from '../../../shared/tui-agent-config'
import { useAppStore } from '@/store'
import { subscribeToPtyData } from '@/components/terminal-pane/pty-dispatcher'
import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
@ -18,20 +18,21 @@ const BRACKETED_PASTE_END = '\x1b[201~'
// opencode / gemini / cursor-agent / copilot) emits `CSI ? 2004 h` (DECSET
// 2004 — bracketed-paste-enable) on its output stream when its input layer
// is wired up. That sequence is the protocol-level "I accept bracketed
// paste" handshake — but on its own it doesn't mean "the input box is
// rendered and visible". OpenCode in particular emits DECSET 2004 during
// its alt-screen setup at ~500ms, then runs a 1.3s splash render with NO
// paste" handshake. For most agents it still does not prove the input box
// is rendered and visible. OpenCode in particular emits DECSET 2004 during
// its alt-screen setup at ~500ms, then runs a 1.3s splash render with no
// data on the PTY, then paints the actual input box at ~1.85s. Pasting
// during the silent gap drops the bytes.
//
// Strategy: take DECSET 2004 as the necessary precondition, then wait for
// the TUI's render burst to finish — defined as `BRACKETED_PASTE_QUIET_MS`
// of stream silence after the most recent post-`?2004h` byte. This
// captures both the fast TUIs (claude/pi/codex emit their setup escapes
// in one burst, then go quiet) and the slow ones (opencode emits, sleeps,
// emits again, then goes quiet). Verified against opencode/claude/pi in
// a node-pty rig: paste lands on the first try with a 1500ms quiet window.
// Default strategy: take DECSET 2004 as the necessary precondition, then
// wait for the TUI's render burst to finish — defined as
// `BRACKETED_PASTE_QUIET_MS` of stream silence after the most recent
// post-`?2004h` byte. This captures both the fast TUIs and the slow ones
// (opencode emits, sleeps, emits again, then goes quiet). Codex opts into a
// faster source-backed path: after DECSET, wait only until its composer
// prompt glyph renders.
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const CODEX_COMPOSER_PROMPT = ''
const BRACKETED_PASTE_QUIET_MS = 1500
// Why: deterministic signal can fail in two ways: (1) the agent never
@ -50,14 +51,12 @@ const READINESS_TIMEOUT_MS = 8000
* PTY. `onTimeout` lets the caller surface a UI hint (e.g. toast) when
* the agent doesn't reach a ready state inside `timeoutMs`.
*
* Readiness combines two stream signals:
* Readiness combines DECSET 2004 with one agent-specific follow-up signal:
* 1. `\x1b[?2004h` (DECSET 2004 bracketed-paste-enable) on the PTY
* output. This is the protocol-level "I accept bracketed paste"
* handshake.
* 2. `BRACKETED_PASTE_QUIET_MS` of silence after the last byte of the
* post-handshake render burst. Captures TUIs (OpenCode) that emit
* DECSET 2004 early and then run a multi-second splash before
* drawing the actual input box.
* 2. Either `BRACKETED_PASTE_QUIET_MS` of silence after the last byte of
* the post-handshake render burst, or Codex's composer prompt glyph.
*/
export async function pasteDraftWhenAgentReady(args: {
tabId: string
@ -69,23 +68,26 @@ export async function pasteDraftWhenAgentReady(args: {
}): Promise<boolean> {
const { tabId, content, agent, submit, timeoutMs, onTimeout } = args
// Why: agents with a documented prefill flag (currently Claude — see
// TUI_AGENT_CONFIG.claude.draftPromptFlag) launch with the URL already
// in their input box. Pasting again would duplicate it. Callers should
// not invoke this helper for those agents; the early return guards
// against accidental double-injection if a stale call slips through.
if (agent && TUI_AGENT_CONFIG[agent].draftPromptFlag) {
const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null
// Why: agents with a native draft prefill mechanism (flag or env var)
// launch with the URL already in their input box. Pasting again would
// duplicate it. Callers should not invoke this helper for those agents;
// the early return guards against accidental double-injection if a stale
// call slips through.
if (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar) {
return false
}
const budget = timeoutMs ?? READINESS_TIMEOUT_MS
const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste'
const ptyId = await waitForPtyId(tabId, budget)
if (!ptyId) {
onTimeout?.()
return false
}
const ready = await waitForInputBoxReady(ptyId, budget)
const ready = await waitForInputBoxReady(ptyId, budget, readySignal)
if (!ready) {
onTimeout?.()
return false
@ -102,21 +104,29 @@ export async function pasteDraftWhenAgentReady(args: {
/**
* Tap the PTY data stream as a side-channel observer (does NOT take over
* the primary handler that feeds xterm) and resolve `true` once we see
* DECSET 2004 *and* the post-handshake render burst settles for
* `BRACKETED_PASTE_QUIET_MS`. Resolves `false` on hard timeout.
* DECSET 2004. Most agents also wait for the post-handshake render burst to
* settle for `BRACKETED_PASTE_QUIET_MS`; Codex waits for its composer prompt
* glyph instead. Resolves `false` on hard timeout.
*
* Why a sidecar subscription:
* - the main pane may attach mid-flight; we must not race against its
* handler registration on the dispatcher's primary slot.
* - DECSET 2004 may straddle two data chunks at ANSI parser boundaries,
* so we keep a small ring of recent bytes and search the union.
* - DECSET 2004 and the Codex composer prompt may straddle two data chunks
* at ANSI parser boundaries, so we keep a small ring of recent bytes and
* search the union.
*/
function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean> {
function waitForInputBoxReady(
ptyId: string,
timeoutMs: number,
readySignal: DraftPasteReadySignal
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let settled = false
let recent = ''
let postHandshakeRecent = ''
let saw2004 = false
let quietTimer: number | null = null
let hardTimer: number | null = null
let unsubscribe: (() => void) | null = null
const finish = (value: boolean): void => {
@ -124,7 +134,9 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean
return
}
settled = true
window.clearTimeout(hardTimer)
if (hardTimer !== null) {
window.clearTimeout(hardTimer)
}
if (quietTimer !== null) {
window.clearTimeout(quietTimer)
}
@ -141,11 +153,40 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean
const observeData = (data: string): void => {
// Why: keep just enough recent bytes that an escape sequence split
// across two IPC frames is still detectable. 64 bytes >> 8-byte
// sequence; cheap and bounded.
recent = (recent + data).slice(-64)
if (!saw2004 && recent.includes(DECSET_BRACKETED_PASTE)) {
// across two IPC frames is still detectable. 512 bytes also covers
// Codex's prompt render around ANSI styling without retaining a large
// terminal scrollback copy.
const combined = recent + data
recent = combined.slice(-512)
if (!saw2004) {
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
if (markerIndex === -1) {
return
}
saw2004 = true
const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length)
if (readySignal === 'codex-composer-prompt') {
if (postHandshakeChunk.includes(CODEX_COMPOSER_PROMPT)) {
finish(true)
return
}
postHandshakeRecent = postHandshakeChunk.slice(-512)
return
}
postHandshakeRecent = postHandshakeChunk.slice(-512)
} else {
if (
readySignal === 'codex-composer-prompt' &&
(data.includes(CODEX_COMPOSER_PROMPT) ||
(postHandshakeRecent + data).includes(CODEX_COMPOSER_PROMPT))
) {
finish(true)
return
}
postHandshakeRecent = (postHandshakeRecent + data).slice(-512)
}
if (readySignal === 'codex-composer-prompt') {
return
}
if (saw2004) {
// Reset the quiet window on every byte we see post-handshake.
@ -175,7 +216,9 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean
unsubscribe = subscribeToPtyData(ptyId, observeData)
}
const hardTimer = window.setTimeout(() => finish(false), timeoutMs)
if (!settled) {
hardTimer = window.setTimeout(() => finish(false), timeoutMs)
}
})
}

View File

@ -7,6 +7,8 @@ export type AgentPromptInjectionMode =
| 'flag-interactive'
| 'stdin-after-start'
export type DraftPasteReadySignal = 'render-quiet-after-bracketed-paste' | 'codex-composer-prompt'
export type TuiAgentConfig = {
detectCmd: string
launchCmd: string
@ -32,12 +34,19 @@ export type TuiAgentConfig = {
* passes the text via this env var instead of pasting after ready. */
draftPromptEnvVar?: string
/** Why: agents that gate first-launch behind a "Do you trust this
* folder?" menu (Cursor-Agent, GitHub Copilot CLI) consume the bracketed
* paste as menu input. Pre-write the same trust artifact the agent writes
* after the user accepts so the menu never fires. The actual file/path
* written lives in src/main/agent-trust-presets.ts; this flag just routes
* the workspace path through the matching preset before the agent spawns. */
preflightTrust?: 'cursor' | 'copilot'
* folder?" menu (Cursor-Agent, GitHub Copilot CLI, Codex) consume the
* bracketed paste as menu input. Pre-write the same trust artifact the
* agent writes after the user accepts so the menu never fires. The actual
* file/path written lives in src/main/agent-trust-presets.ts; this flag
* just routes the workspace path through the matching preset before the
* agent spawns. */
preflightTrust?: 'cursor' | 'copilot' | 'codex'
/** Why: most TUIs need both bracketed-paste enablement and a quiet render
* window before pasted bytes reliably land in the composer. Codex can use
* a stronger signal from its own renderer: chat_composer.rs writes the
* `` prompt only when the composer row exists, so Orca can paste as soon
* as that prompt appears after bracketed paste is enabled. */
draftPasteReadySignal?: DraftPasteReadySignal
}
// Why: the new-workspace handoff depends on three pieces of per-agent
@ -62,7 +71,14 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
detectCmd: 'codex',
launchCmd: 'codex',
expectedProcess: 'codex',
promptInjectionMode: 'argv'
promptInjectionMode: 'argv',
// Why: Codex's positional prompt auto-submits the first turn, so Orca
// must still paste a draft. The Codex TUI enables bracketed paste before
// the first render, then chat_composer.rs emits `` when the composer row
// is visible. Waiting for that prompt skips the generic quiet timer while
// avoiding startup/onboarding screens that ignore paste.
preflightTrust: 'codex',
draftPasteReadySignal: 'codex-composer-prompt'
},
autohand: {
detectCmd: 'autohand',