diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index df328ad64..4b4575fd6 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -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('"', '\\"') +} diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index e6c87c52b..f71ed9e44 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -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.""] + * 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 diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 45c315585..447294af0 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -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']) diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 179ce2572..4d40183e2 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -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, '\\$&') } diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index 9ded836a3..b31913178 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f4b7c93fb..87e914908 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1118,7 +1118,10 @@ export type PreloadApi = { hermesStatus: () => Promise } agentTrust: { - markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + }) => Promise } preflight: PreflightApi notifications: { diff --git a/src/preload/index.ts b/src/preload/index.ts index d4a93db12..f8330c509 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1149,8 +1149,10 @@ const api = { }, agentTrust: { - markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise => - ipcRenderer.invoke('agentTrust:markTrusted', args) + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) }, preflight: { diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts new file mode 100644 index 000000000..b4845cfed --- /dev/null +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -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 { + await Promise.resolve() + await Promise.resolve() +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 5dd27b91d..c0ebebf0d 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -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 { 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 { +function waitForInputBoxReady( + ptyId: string, + timeoutMs: number, + readySignal: DraftPasteReadySignal +): Promise { return new Promise((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 { // 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 finish(false), timeoutMs) + if (!settled) { + hardTimer = window.setTimeout(() => finish(false), timeoutMs) + } }) } diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 60f12866a..302f6c302 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -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 = { 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',