From 1ab3d9a04f02adaba43b04fb887ec2a0985a7864 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:04:15 +0200 Subject: [PATCH] fix(headless): keep idle timeout off during interactive tools --- src/headless-events.ts | 9 ++++++++ src/headless.ts | 20 ++++++++++++++-- src/tests/headless-events.test.ts | 38 ++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/headless-events.ts b/src/headless-events.ts index 190ac99a1..f80acfccd 100644 --- a/src/headless-events.ts +++ b/src/headless-events.ts @@ -70,6 +70,7 @@ export const IDLE_TIMEOUT_MS = 15_000 // between tool calls (e.g. after mkdir, before writing files). Use a // longer idle timeout to avoid killing the session prematurely (#808). export const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000 +const INTERACTIVE_HEADLESS_TOOLS = new Set(['ask_user_questions', 'secure_env_collect']) export function isTerminalNotification(event: Record): boolean { if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false @@ -89,6 +90,14 @@ export function isMilestoneReadyNotification(event: Record): bo return /milestone\s+m\d+.*ready/i.test(String(event.message ?? '')) } +export function isInteractiveHeadlessTool(toolName: string | undefined): boolean { + return INTERACTIVE_HEADLESS_TOOLS.has(String(toolName ?? '')) +} + +export function shouldArmHeadlessIdleTimeout(toolCallCount: number, interactiveToolCount: number): boolean { + return toolCallCount > 0 && interactiveToolCount === 0 +} + // --------------------------------------------------------------------------- // Quick Command Detection // --------------------------------------------------------------------------- diff --git a/src/headless.ts b/src/headless.ts index cd0d86124..d277c6725 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -30,6 +30,8 @@ import { FIRE_AND_FORGET_METHODS, IDLE_TIMEOUT_MS, NEW_MILESTONE_IDLE_TIMEOUT_MS, + isInteractiveHeadlessTool, + shouldArmHeadlessIdleTimeout, EXIT_SUCCESS, EXIT_ERROR, EXIT_BLOCKED, @@ -367,6 +369,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): let exitCode = 0 let milestoneReady = false // tracks "Milestone X ready." for auto-chaining const recentEvents: TrackedEvent[] = [] + const interactiveToolCallIds = new Set() // JSON batch mode: cost aggregation (cumulative-max pattern per K004) let cumulativeCostUsd = 0 @@ -460,7 +463,7 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): function resetIdleTimer(): void { if (idleTimer) clearTimeout(idleTimer) - if (toolCallCount > 0) { + if (shouldArmHeadlessIdleTimeout(toolCallCount, interactiveToolCallIds.size)) { idleTimer = setTimeout(() => { completed = true resolveCompletion() @@ -484,6 +487,20 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): client.onEvent((event) => { const eventObj = event as unknown as Record trackEvent(eventObj) + + const eventType = String(eventObj.type ?? '') + if (eventType === 'tool_execution_start') { + const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? '') + if (toolCallId && isInteractiveHeadlessTool(String(eventObj.toolName ?? ''))) { + interactiveToolCallIds.add(toolCallId) + } + } else if (eventType === 'tool_execution_end') { + const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? '') + if (toolCallId) { + interactiveToolCallIds.delete(toolCallId) + } + } + resetIdleTimer() // Answer injector: observe events for question metadata @@ -492,7 +509,6 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number): // --json / --output-format stream-json: forward events as JSONL to stdout (filtered if --events) // --output-format json (batch mode): suppress streaming, track cost for final result if (options.json && options.outputFormat === 'stream-json') { - const eventType = String(eventObj.type ?? '') if (!options.eventFilter || options.eventFilter.has(eventType)) { process.stdout.write(JSON.stringify(eventObj) + '\n') } diff --git a/src/tests/headless-events.test.ts b/src/tests/headless-events.test.ts index 60c0695e7..4aeae8f39 100644 --- a/src/tests/headless-events.test.ts +++ b/src/tests/headless-events.test.ts @@ -150,7 +150,15 @@ test('empty filter blocks all events', () => { assert.ok(!shouldEmit('message_update')) }) -import { mapStatusToExitCode, EXIT_SUCCESS, EXIT_ERROR, EXIT_BLOCKED, EXIT_CANCELLED } from '../headless-events.js' +import { + mapStatusToExitCode, + EXIT_SUCCESS, + EXIT_ERROR, + EXIT_BLOCKED, + EXIT_CANCELLED, + isInteractiveHeadlessTool, + shouldArmHeadlessIdleTimeout, +} from '../headless-events.js' // ─── mapStatusToExitCode ───────────────────────────────────────────────── @@ -185,3 +193,31 @@ test('mapStatusToExitCode: "cancelled" returns EXIT_CANCELLED', () => { test('mapStatusToExitCode: unknown status returns EXIT_ERROR', () => { assert.equal(mapStatusToExitCode('unknown'), EXIT_ERROR) }) + +test('isInteractiveHeadlessTool: ask_user_questions is interactive', () => { + assert.equal(isInteractiveHeadlessTool('ask_user_questions'), true) +}) + +test('isInteractiveHeadlessTool: secure_env_collect is interactive', () => { + assert.equal(isInteractiveHeadlessTool('secure_env_collect'), true) +}) + +test('isInteractiveHeadlessTool: non-interactive tools stay false', () => { + assert.equal(isInteractiveHeadlessTool('bash'), false) + assert.equal(isInteractiveHeadlessTool(undefined), false) +}) + +test('shouldArmHeadlessIdleTimeout: arms after tool calls when no interactive tool is in flight', () => { + assert.equal(shouldArmHeadlessIdleTimeout(1, 0), true) + assert.equal(shouldArmHeadlessIdleTimeout(3, 0), true) +}) + +test('shouldArmHeadlessIdleTimeout: stays disarmed while interactive tools are in flight (#3714)', () => { + assert.equal(shouldArmHeadlessIdleTimeout(1, 1), false) + assert.equal(shouldArmHeadlessIdleTimeout(5, 2), false) +}) + +test('shouldArmHeadlessIdleTimeout: stays disarmed before any tool call has started', () => { + assert.equal(shouldArmHeadlessIdleTimeout(0, 0), false) + assert.equal(shouldArmHeadlessIdleTimeout(0, 1), false) +})