Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/fixed-issue-4180.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Claude one-shot prompts now wait for startup dialogs to clear before sending.
13 changes: 12 additions & 1 deletion server/lib/tuiHandshake.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions server/lib/tuiHandshake.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
Expand Down
75 changes: 71 additions & 4 deletions server/lib/tuiPromptRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,21 @@ 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,
OUTPUT_BUFFER_HEADROOM,
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
120 changes: 116 additions & 4 deletions server/lib/tuiPromptRunner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down