diff --git a/.changelog/next/fixed-issue-4180.md b/.changelog/next/fixed-issue-4180.md new file mode 100644 index 0000000000..2ed3b23486 --- /dev/null +++ b/.changelog/next/fixed-issue-4180.md @@ -0,0 +1 @@ +- Claude one-shot prompts now wait for startup dialogs to clear before sending. diff --git a/server/lib/tuiHandshake.js b/server/lib/tuiHandshake.js index eb7e488d7a..33d4574e1f 100644 --- a/server/lib/tuiHandshake.js +++ b/server/lib/tuiHandshake.js @@ -342,6 +342,10 @@ export function createInputReadyTracker({ readyTextPattern = null, directLaunch let needsAutoModeChoice = false; let autoModeAnswered = false; let tail = ''; + // node-pty can split `ESC[?2004h` across reads. Keep only the trailing + // prefix of that exact toggle so the next chunk can complete it without + // treating unrelated escape traffic as a readiness signal. + let rawTail = ''; return { // Ready once the TUI has RE-ENABLED bracketed-paste mode after the launch // shell turned it off to run the command — for claude that means its input @@ -365,10 +369,17 @@ export function createInputReadyTracker({ readyTextPattern = null, directLaunch // strippedText: ANSI-stripped chunk (the trust-gate / composer text). observe(rawText, strippedText) { if (rawText) { - for (const m of rawText.matchAll(BRACKETED_PASTE_MODE_PATTERN)) { + const raw = rawTail + rawText; + rawTail = ''; + for (const m of raw.matchAll(BRACKETED_PASTE_MODE_PATTERN)) { if (m[1] === 'l') { pasteModeOn = false; sawCommandRun = true; } else pasteModeOn = true; } + const lastEscape = raw.lastIndexOf('\x1b'); + const possibleTogglePrefix = lastEscape === -1 ? '' : raw.slice(lastEscape); + if (possibleTogglePrefix && '\x1b[?2004'.startsWith(possibleTogglePrefix)) { + rawTail = possibleTogglePrefix; + } } if (strippedText) { tail = (tail + strippedText.replace(/\s+/g, '')).slice(-OBSERVE_TAIL_MAX_LEN); diff --git a/server/lib/tuiHandshake.test.js b/server/lib/tuiHandshake.test.js index 4cd0ed2b09..24b75094e4 100644 --- a/server/lib/tuiHandshake.test.js +++ b/server/lib/tuiHandshake.test.js @@ -1382,6 +1382,14 @@ describe('createInputReadyTracker', () => { expect(tracker.ready).toBe(true); }); + it('directLaunch: carries a split bracketed-paste toggle across PTY chunks', () => { + const tracker = createInputReadyTracker({ directLaunch: true }); + tracker.observe('\x1b[?2004', ''); + expect(tracker.ready).toBe(false); + tracker.observe('h', ''); + expect(tracker.ready).toBe(true); + }); + it('directLaunch + readyTextPattern: still waits for the composer marker', () => { const tracker = createInputReadyTracker({ readyTextPattern: AGY_INPUT_READY_PATTERN, directLaunch: true }); tracker.observe(PASTE_ON, 'Signing in...'); diff --git a/server/lib/tuiPromptRunner.js b/server/lib/tuiPromptRunner.js index ad592da7d7..30364e5158 100644 --- a/server/lib/tuiPromptRunner.js +++ b/server/lib/tuiPromptRunner.js @@ -57,6 +57,7 @@ import { scheduleSubmitEnters, SELF_CLEARING_RESUBMIT_POLL_MS, PASTE_DEADLINE_MS, + TUI_INPUT_READY_DEADLINE_MS, READY_POLL_INTERVAL_MS, READY_IDLE_THRESHOLD_MS, OUTPUT_BUFFER_CAP, @@ -64,11 +65,13 @@ import { RAW_BUFFER_CAP, RAW_BUFFER_HEADROOM, buildTuiInvocation, + createInputReadyTracker, detectMissingTuiBinary, createSelfClearingSignalGate, } from './tuiHandshake.js'; import { buildCliChildEnv } from './cliChildEnv.js'; import { isCodexCommand } from './codex.js'; +import { isClaudeCommand } from './providerModels.js'; // One-shot defaults that don't apply to the long-running agent path: // - hard run cap (5 min vs unbounded for agents) @@ -291,6 +294,16 @@ ${prompt}`; let outputBufferTruncated = false; const streamingStrip = createStreamingAnsiStripper(); + // This PTY spawns the TUI directly, so the TUI's first paste-mode ON is the + // positive ready signal. Claude gets that positive gate; other providers + // retain their existing idle/deadline behavior. + const inputReady = createInputReadyTracker({ directLaunch: true }); + const requiresInputReady = isClaudeCommand(command); + // needsTrust has no self-clearing latch in the tracker, so this flag is + // load-bearing to avoid re-sending Enter on every poll tick. needsAutoModeChoice + // doesn't need a matching flag below — ackAutoModeChoice() latches it false + // permanently on the tracker itself (see autoModeAnswered in tuiHandshake.js). + let trustAccepted = false; // The wrapped prompt directs the model to write its COMPLETE response to // `responseFilePath` and then finish. That file appearing is the model's @@ -488,6 +501,9 @@ ${prompt}`; if (rawBuffer.length > RAW_BUFFER_HEADROOM) rawBuffer = rawBuffer.slice(-RAW_BUFFER_CAP); const stripped = streamingStrip(text); + // The readiness tracker needs both streams: bracketed-paste transitions + // survive only in raw output, while startup dialog text is ANSI-stripped. + inputReady.observe(text, stripped); if (stripped) { if (postPasteStripped !== null) postPasteStripped += stripped; outputBuffer += stripped; @@ -707,6 +723,23 @@ ${prompt}`; }, PASTE_MARKER_POLL_MS); }; + const dismissStartupDialog = (keys, dialog) => { + try { + ptyProcess.write(keys); + return true; + } catch (err) { + finish({ + success: false, + exitCode: 1, + error: `Failed to dismiss ${dialog}: ${err.message}`, + reason: 'startup-dialog-write-failed', + }).catch((finishErr) => { + console.error(`❌ TUI run ${runId} ${dialog} dismissal failed: ${finishErr?.message || finishErr}`); + }); + return false; + } + }; + /** * Re-deliver the prompt while a self-clearing provider signal's window is * open. Mirrors `resubmitAfterSignal` on the long-running agent path — see @@ -738,10 +771,9 @@ ${prompt}`; } }; - // Ready watch — paste only once the TUI banner finishes repainting AND - // we've had at least promptDelayMs of runtime. Falls back to forcing - // the paste after PASTE_DEADLINE_MS so a silent provider still gets - // the prompt. + // Ready watch — Claude dismisses known startup dialogs before sending + // anything, then waits for its positive input-ready signal. Other providers + // retain the existing idle/deadline fallback unchanged. readyTimer = setInterval(() => { if (finalized || promptSentAt) { clearInterval(readyTimer); @@ -750,6 +782,41 @@ ${prompt}`; } const now = Date.now(); const elapsed = now - startTime; + if (requiresInputReady) { + if (inputReady.needsTrust && !trustAccepted) { + trustAccepted = true; + dismissStartupDialog('\r', 'folder-trust prompt'); + return; + } + if (inputReady.needsAutoModeChoice) { + // Select "No, keep don't ask" so a one-shot run never rewrites the + // user's global Claude permission default as a startup side effect. + if (dismissStartupDialog('\x1b[B\r', 'auto-mode prompt')) { + // The offer paints over an already-live composer, so this re-arms + // that verified paste-mode signal. Returning still leaves a full + // ready-poll interval for Ink to redraw before any paste can start. + inputReady.ackAutoModeChoice(); + } + return; + } + if (inputReady.ready && elapsed >= promptDelayMs) { + sendPrompt('input-ready'); + return; + } + if (elapsed >= TUI_INPUT_READY_DEADLINE_MS) { + clearInterval(readyTimer); + readyTimer = null; + finish({ + success: false, + exitCode: 1, + error: `${command} did not present an input prompt within ${Math.round(TUI_INPUT_READY_DEADLINE_MS / 1000)}s, so no prompt was sent.`, + reason: 'tui-not-ready', + }).catch((err) => { + console.error(`❌ TUI run ${runId} input-readiness failure could not finalize: ${err?.message || err}`); + }); + } + return; + } if (elapsed >= PASTE_DEADLINE_MS) { sendPrompt('fallback'); return; diff --git a/server/lib/tuiPromptRunner.test.js b/server/lib/tuiPromptRunner.test.js index b88b3d1c54..27c573cd99 100644 --- a/server/lib/tuiPromptRunner.test.js +++ b/server/lib/tuiPromptRunner.test.js @@ -104,7 +104,7 @@ vi.mock('./fileUtils.js', async () => { import { cleanTuiResponse, resolveTuiResponseText, executeTuiRun } from './tuiPromptRunner.js'; import { markHostShuttingDown, resetHostShutdownFlagForTests } from './hostShutdown.js'; -import { SELF_CLEARING_RESUBMIT_INTERVAL_MS, SELF_CLEARING_RESUBMIT_ECHO_MS } from './tuiHandshake.js'; +import { SELF_CLEARING_RESUBMIT_INTERVAL_MS, SELF_CLEARING_RESUBMIT_ECHO_MS, TUI_INPUT_READY_DEADLINE_MS } from './tuiHandshake.js'; const makeFakePty = () => { const fake = { @@ -505,6 +505,118 @@ describe('executeTuiRun', () => { }); }); + describe('startup dialogs', () => { + it('confirms the Claude folder-trust gate and waits for input readiness before pasting', async () => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'], + }); + const provider = { id: 'claude', type: 'tui', command: 'claude', tuiPromptDelayMs: 50 }; + const promise = executeTuiRun({ + runId: 'run-claude-trust-gate', provider, prompt: 'return one structured response', + workspacePath: TEST_WORKSPACE, timeout: 60000, + }); + await flushAsync(); + + const pty = ptyInstances[0]; + pty.emitData('Is this a project you trust?\n1. Yes, I trust this folder\n2. No, exit\n'); + await vi.advanceTimersByTimeAsync(400); + + expect(pty.write).toHaveBeenCalledWith('\r'); + expect(pty.write).not.toHaveBeenCalledWith(expect.stringContaining('\x1b[200~')); + + // A blind fallback must not paste into a known startup dialog. + await vi.advanceTimersByTimeAsync(11000); + expect(pty.write).not.toHaveBeenCalledWith(expect.stringContaining('\x1b[200~')); + + // Claude's own bracketed-paste mode is the positive direct-PTY signal. + // node-pty may split its raw control sequence across callbacks. + pty.emitData('\x1b[?2004'); + pty.emitData('h'); + await vi.advanceTimersByTimeAsync(400); + expect(pty.write).toHaveBeenCalledWith(expect.stringContaining('\x1b[200~')); + + pty.emitExit({ exitCode: 0 }); + await promise; + }); + + it('declines Claude auto-mode and pastes only after dismissing the offer', async () => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'], + }); + const provider = { id: 'claude', type: 'tui', command: 'claude', tuiPromptDelayMs: 50 }; + const promise = executeTuiRun({ + runId: 'run-claude-auto-mode', provider, prompt: 'return one structured response', + workspacePath: TEST_WORKSPACE, timeout: 60000, + }); + await flushAsync(); + + const pty = ptyInstances[0]; + pty.emitData( + '\x1b[?2004hMake auto mode your default permission mode?\n' + + '1. Yes, set auto mode as my default permission mode\n' + + "2. No, keep don't ask\n", + ); + await vi.advanceTimersByTimeAsync(400); + + expect(pty.write).toHaveBeenCalledWith('\x1b[B\r'); + expect(pty.write).not.toHaveBeenCalledWith(expect.stringContaining('\x1b[200~')); + + await vi.advanceTimersByTimeAsync(400); + expect(pty.write).toHaveBeenCalledWith(expect.stringContaining('\x1b[200~')); + + pty.emitExit({ exitCode: 0 }); + await promise; + }); + + it('keeps non-Claude TUIs on the existing idle fallback when startup output resembles Claude trust text', async () => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'], + }); + const provider = { id: 'opencode', type: 'tui', command: 'opencode', tuiPromptDelayMs: 50 }; + const promise = executeTuiRun({ + runId: 'run-non-claude-trust-text', provider, prompt: 'return one structured response', + workspacePath: TEST_WORKSPACE, timeout: 60000, + }); + await flushAsync(); + + const pty = ptyInstances[0]; + pty.emitData('Is this a project you trust?\n1. Yes, I trust this folder\n2. No, exit\n'); + await vi.advanceTimersByTimeAsync(2000); + + expect(pty.write).not.toHaveBeenCalledWith('\r'); + expect(pty.write).toHaveBeenCalledWith(expect.stringContaining('\x1b[200~')); + + pty.emitExit({ exitCode: 0 }); + await promise; + }); + + it('fails Claude startup after its input-readiness deadline instead of blind-pasting', async () => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'], + }); + const provider = { id: 'claude', type: 'tui', command: 'claude', tuiPromptDelayMs: 50 }; + const runId = 'run-claude-not-ready'; + const promise = executeTuiRun({ + runId, provider, prompt: 'return one structured response', + workspacePath: TEST_WORKSPACE, timeout: 60000, + }); + await flushAsync(); + + ptyInstances[0].emitData('Claude Code is still starting\n'); + await vi.advanceTimersByTimeAsync(TUI_INPUT_READY_DEADLINE_MS + 500); + await promise; + + expect(ptyInstances[0].write).not.toHaveBeenCalledWith(expect.stringContaining('\x1b[200~')); + expect(runnerMocks.finalizeRunRecord).toHaveBeenCalledWith(expect.objectContaining({ + runId, + success: false, + exitCode: 1, + error: expect.stringContaining('did not present an input prompt'), + extras: expect.objectContaining({ completionReason: 'tui-not-ready' }), + })); + }); + }); + describe('completion paths', () => { it('finishes with reason "idle-complete" once output stays idle past tuiOneShotIdleMs after the first response chunk', async () => { vi.useFakeTimers({ @@ -655,7 +767,7 @@ describe('executeTuiRun', () => { await flushAsync(); const pty = ptyInstances[0]; - pty.emitData('claude code ready> '); + pty.emitData('\x1b[?2004hclaude code ready> '); await vi.advanceTimersByTimeAsync(2000); // paste await vi.advanceTimersByTimeAsync(4000); // enter pty.emitData('model thinking…'); // arms idleWatchTimer @@ -695,7 +807,7 @@ describe('executeTuiRun', () => { const pty = ptyInstances[0]; // Pre-paste banner lets the ready-watch paste — but NOTHING is emitted // after the paste, so idleWatchTimer never arms. - pty.emitData('claude code ready> '); + pty.emitData('\x1b[?2004hclaude code ready> '); await vi.advanceTimersByTimeAsync(2000); // ready-watch pastes → response-file watcher starts await vi.advanceTimersByTimeAsync(4000); // enter submitted; still zero post-paste output @@ -1053,7 +1165,7 @@ describe('executeTuiRun', () => { await flushAsync(); const pty = ptyInstances[0]; - pty.emitData('claude code ready> '); + pty.emitData('\x1b[?2004hclaude code ready> '); await vi.advanceTimersByTimeAsync(2000); // paste → response-file watcher armed await vi.advanceTimersByTimeAsync(4000); // enter