diff --git a/src/headless-ui.ts b/src/headless-ui.ts index ca9e0d4e9..7791c5b48 100644 --- a/src/headless-ui.ts +++ b/src/headless-ui.ts @@ -40,6 +40,16 @@ export interface ProgressContext { isError?: boolean; // tool execution ended with an error } +/** Context for periodic headless heartbeat lines during long quiet waits. */ +export interface HeadlessHeartbeatContext { + elapsedMs: number; + quietMs: number; + totalEvents: number; + toolCallCount: number; + lastEventType?: string; + lastEventDetail?: string; +} + // --------------------------------------------------------------------------- // ANSI Color Helpers // --------------------------------------------------------------------------- @@ -102,7 +112,8 @@ const TUI_FOOTER_STATUS_KEYS = new Set([ function formatCategorizedNotification(message: string): string | null { if (message.startsWith("MCP client ready")) return `[mcp] ${message}`; if (message.startsWith("Web search:")) return `[search] ${message}`; - if (message.startsWith("Native Anthropic web search")) return `[search] ${message}`; + if (message.startsWith("Native Anthropic web search")) + return `[search] ${message}`; if (message.includes("dispatching") && message.includes("subagents")) return `[parallel] ${message}`; if (message.startsWith("Slice-parallel:")) return `[parallel] ${message}`; @@ -270,6 +281,11 @@ function shortPath(p: unknown): string { function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; + if (ms >= 60_000) { + const minutes = Math.floor(ms / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`; + } const s = (ms / 1000).toFixed(1); return `${s}s`; } @@ -403,7 +419,9 @@ export function formatProgress( if (statusKey) { const label = parsePhaseLabel(statusKey, msg); if (label) { - const labelTag = isPhaseStatusKey(statusKey) ? tag("phase") : tag("status"); + const labelTag = isPhaseStatusKey(statusKey) + ? tag("phase") + : tag("status"); return `${c.cyan}${labelTag}${label}${c.reset}`; } } @@ -474,6 +492,21 @@ export function formatCostLine( return `${c.dim}${tag("cost")}$${costUsd.toFixed(4)} (${inputTokens + outputTokens} tokens)${c.reset}`; } +/** + * Format a periodic liveness line for headless runs. + * + * Purpose: make long model calls and quiet auto-mode phases observable without + * changing machine-readable JSON output. + */ +export function formatHeadlessHeartbeat(ctx: HeadlessHeartbeatContext): string { + const lastEvent = ctx.lastEventType + ? ctx.lastEventDetail + ? `${ctx.lastEventType}:${ctx.lastEventDetail}` + : ctx.lastEventType + : "none"; + return `${c.dim}${tag("headless")}still running ${formatDuration(ctx.elapsedMs)}; quiet ${formatDuration(ctx.quietMs)}; last=${lastEvent}; events=${ctx.totalEvents}; tools=${ctx.toolCallCount}${c.reset}`; +} + // --------------------------------------------------------------------------- // Phase Label Parser // --------------------------------------------------------------------------- diff --git a/src/headless.ts b/src/headless.ts index d38a8e07e..e728bdc39 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -45,6 +45,7 @@ import type { HeadlessJsonResult, OutputFormat } from "./headless-types.js"; import { VALID_OUTPUT_FORMATS } from "./headless-types.js"; import type { ExtensionUIRequest, ProgressContext } from "./headless-ui.js"; import { + formatHeadlessHeartbeat, formatProgress, formatTextEnd, formatTextStart, @@ -74,6 +75,8 @@ import { traceEvent, } from "./resources/extensions/sf/trace-collector.js"; +const HEADLESS_HEARTBEAT_INTERVAL_MS = 60_000; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -552,6 +555,7 @@ async function runHeadlessOnce( let exitCode = 0; let milestoneReady = false; // tracks "Milestone X ready." for auto-chaining const recentEvents: TrackedEvent[] = []; + let lastVisibleProgressAt = Date.now(); const interactiveToolCallIds = new Set(); // JSON batch mode: cost aggregation (cumulative-max pattern per K004) @@ -756,6 +760,11 @@ async function runHeadlessOnce( recentEvents.push({ type, timestamp: Date.now(), detail }); if (recentEvents.length > 20) recentEvents.shift(); } + function writeHeadlessLine(line: string): void { + process.stderr.write(line + "\n"); + lastVisibleProgressAt = Date.now(); + } + // Client started flag — replaces old stdinWriter null-check let clientStarted = false; @@ -830,6 +839,27 @@ async function runHeadlessOnce( resolveCompletion(); }, options.timeout) : null; + const heartbeatTimer = + !options.json && options.outputFormat === "text" + ? setInterval(() => { + if (completed) return; + const now = Date.now(); + const quietMs = now - lastVisibleProgressAt; + if (quietMs < HEADLESS_HEARTBEAT_INTERVAL_MS) return; + const lastEvent = recentEvents[recentEvents.length - 1]; + writeHeadlessLine( + formatHeadlessHeartbeat({ + elapsedMs: now - startTime, + quietMs, + totalEvents, + toolCallCount, + lastEventType: lastEvent?.type, + lastEventDetail: lastEvent?.detail, + }), + ); + }, HEADLESS_HEARTBEAT_INTERVAL_MS) + : null; + // Event handler client.onEvent((event) => { @@ -945,6 +975,7 @@ async function runHeadlessOnce( // --- Text streaming --- if (ameType === "text_start") { inTextBlock = true; + lastVisibleProgressAt = Date.now(); process.stderr.write(formatTextStart()); } else if (ameType === "text_delta") { const delta = String(ame.delta ?? ame.text ?? ""); @@ -954,10 +985,12 @@ async function runHeadlessOnce( inTextBlock = true; process.stderr.write(formatTextStart()); } + lastVisibleProgressAt = Date.now(); process.stderr.write(delta); } } else if (ameType === "text_end") { if (inTextBlock) { + lastVisibleProgressAt = Date.now(); process.stderr.write(formatTextEnd() + "\n"); inTextBlock = false; } @@ -966,6 +999,7 @@ async function runHeadlessOnce( // --- Thinking streaming --- else if (ameType === "thinking_start") { inThinkingBlock = true; + lastVisibleProgressAt = Date.now(); process.stderr.write(formatThinkingStart()); } else if (ameType === "thinking_delta") { const delta = String(ame.delta ?? ame.text ?? ""); @@ -974,10 +1008,12 @@ async function runHeadlessOnce( inThinkingBlock = true; process.stderr.write(formatThinkingStart()); } + lastVisibleProgressAt = Date.now(); process.stderr.write(delta); } } else if (ameType === "thinking_end") { if (inThinkingBlock) { + lastVisibleProgressAt = Date.now(); process.stderr.write(formatThinkingEnd() + "\n"); inThinkingBlock = false; } @@ -1015,7 +1051,7 @@ async function runHeadlessOnce( thinkingBuffer.trim() && (eventType === "tool_execution_start" || eventType === "message_end") ) { - process.stderr.write(formatThinkingLine(thinkingBuffer) + "\n"); + writeHeadlessLine(formatThinkingLine(thinkingBuffer)); thinkingBuffer = ""; } @@ -1041,7 +1077,7 @@ async function runHeadlessOnce( const line = formatProgress(eventObj, ctx); if (line && line !== lastProgressLine) { - process.stderr.write(line + "\n"); + writeHeadlessLine(line); lastProgressLine = line; } } @@ -1183,6 +1219,7 @@ async function runHeadlessOnce( ); } if (timeoutTimer) clearTimeout(timeoutTimer); + if (heartbeatTimer) clearInterval(heartbeatTimer); if (idleTimer) clearTimeout(idleTimer); // Emit batch JSON result if in json mode before exiting if (options.outputFormat === "json") { @@ -1201,6 +1238,7 @@ async function runHeadlessOnce( process.stderr.write( `[headless] Error: Failed to start RPC session: ${err instanceof Error ? err.message : String(err)}\n`, ); + if (heartbeatTimer) clearInterval(heartbeatTimer); if (timeoutTimer) clearTimeout(timeoutTimer); process.exit(1); } @@ -1287,8 +1325,8 @@ async function runHeadlessOnce( } if (!options.json) { - process.stderr.write( - `[headless] Running /sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}...\n`, + writeHeadlessLine( + `[headless] Running /sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}...`, ); } @@ -1348,6 +1386,7 @@ async function runHeadlessOnce( // Cleanup if (timeoutTimer) clearTimeout(timeoutTimer); + if (heartbeatTimer) clearInterval(heartbeatTimer); if (idleTimer) clearTimeout(idleTimer); for (const timer of pendingResponseTimers.values()) clearTimeout(timer); pendingResponseTimers.clear(); diff --git a/src/tests/headless-progress.test.ts b/src/tests/headless-progress.test.ts index 1965a4c89..dfd168377 100644 --- a/src/tests/headless-progress.test.ts +++ b/src/tests/headless-progress.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import type { ProgressContext } from "../headless-ui.js"; import { formatCostLine, + formatHeadlessHeartbeat, formatProgress, formatThinkingLine, summarizeToolArgs, @@ -16,6 +17,24 @@ function ctx(overrides: Partial = {}): ProgressContext { } describe("formatProgress", () => { + describe("formatHeadlessHeartbeat", () => { + it("shows liveness details during quiet headless waits", () => { + const result = formatHeadlessHeartbeat({ + elapsedMs: 125_000, + quietMs: 61_000, + totalEvents: 12, + toolCallCount: 3, + lastEventType: "agent_start", + }); + + assert.ok(result.includes("still running 2m5s")); + assert.ok(result.includes("quiet 1m1s")); + assert.ok(result.includes("last=agent_start")); + assert.ok(result.includes("events=12")); + assert.ok(result.includes("tools=3")); + }); + }); + describe("tool_execution_start", () => { it("shows tool name and summarized args in verbose mode", () => { const result = formatProgress(