/** * headless.ts — Headless Orchestrator for `sf headless`. * * Purpose: run any /sf subcommand without a TUI by spawning a child process in * RPC mode, auto-responding to extension UI requests, and streaming progress to * stderr. This lets CI pipelines, test harnesses, and remote orchestrators drive * sf-run programmatically. * * Consumer: CLI entry point (commands-handlers.ts) when the user runs * `sf headless `. */ import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; import type { SessionInfo } from "@singularity-forge/pi-coding-agent"; import { RpcClient, SessionManager } from "@singularity-forge/pi-coding-agent"; import { error, formatStructuredError } from "./errors.js"; import { AnswerInjector, loadAndValidateAnswerFile, } from "./headless-answers.js"; import { bootstrapProject, buildAutoBootstrapContext, hasProjectMilestones, loadContext, } from "./headless-context.js"; import { EXIT_BLOCKED, EXIT_CANCELLED, EXIT_ERROR, EXIT_RELOAD, EXIT_SUCCESS, FIRE_AND_FORGET_METHODS, IDLE_TIMEOUT_MS, isAutoResumeScheduledNotification, isBlockedNotification, isInteractiveHeadlessTool, isMilestoneReadyNotification, isMilestoneReadyText, isPauseNotification, isQuickCommand, isTerminalNotification, MULTI_TURN_DEADLOCK_BACKSTOP_MS, mapStatusToExitCode, NEW_MILESTONE_IDLE_TIMEOUT_MS, shouldArmHeadlessIdleTimeout, shouldRestartHeadlessRun, } from "./headless-events.js"; 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 { extractAssistantPreviewDelta, formatHeadlessHeartbeat, formatProgress, formatPromptTraceLines, formatTextEnd, formatTextLine, formatTextStart, formatThinkingEnd, formatThinkingLine, formatThinkingStart, handleExtensionUIRequest, startSupervisedStdinReader, summarizeToolArgs, } from "./headless-ui.js"; import { getProjectSessionsDir } from "./project-sessions.js"; import { ensureSfSymlink, externalSfRoot, hasExternalProjectState, } from "./resources/extensions/sf/repo-identity.js"; import { completeSpan, flushTrace, getActiveTrace, initTraceCollector, isTraceEnabled, setTraceCost, setTraceExitCode, startToolSpan, startUnitSpan, traceError, traceEvent, } from "./resources/extensions/sf/trace-collector.js"; const HEADLESS_HEARTBEAT_INTERVAL_MS = 60_000; interface HeadlessTimeoutSolverEvalRecord { runId: string; reportPath: string; dbRecorded: boolean; } async function runHeadlessTimeoutSolverEval( basePath: string, ): Promise { try { const evalModulePath = "./resources/extensions/sf/autonomous-solver-eval.js"; const { runAutomaticAutonomousSolverEval } = await import(evalModulePath); const result = await runAutomaticAutonomousSolverEval({ basePath, reason: "headless-autonomous-timeout", }); if (result?.ok && result.report?.dbRecorded) { process.stderr.write( `[headless] Autonomous solver eval recorded after timeout: ${result.report.reportPath}\n`, ); return { runId: result.report.runId, reportPath: result.report.reportPath, dbRecorded: true, }; } else if (result?.ok && result.report) { process.stderr.write( `[headless] Autonomous solver eval wrote ${result.report.reportPath}, but DB evidence was not recorded.\n`, ); return { runId: result.report.runId, reportPath: result.report.reportPath, dbRecorded: false, }; } else if (!result?.skipped) { process.stderr.write( `[headless] Autonomous solver eval after timeout failed: ${result?.error ?? "unknown error"}\n`, ); } } catch (err) { process.stderr.write( `[headless] Autonomous solver eval after timeout failed: ${err instanceof Error ? err.message : String(err)}\n`, ); } return null; } async function recordHeadlessRunBestEffort( basePath: string, entry: Record, ): Promise { try { const dynamicToolsPath = "./resources/extensions/sf/bootstrap/dynamic-tools.js"; const { ensureDbOpen } = await import(dynamicToolsPath); if (!(await ensureDbOpen(basePath))) return; const sfDbPath = "./resources/extensions/sf/sf-db.js"; const { recordHeadlessRun } = await import(sfDbPath); recordHeadlessRun(entry); } catch (err) { process.stderr.write( `[headless] DB run record failed: ${err instanceof Error ? err.message : String(err)}\n`, ); } } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * Parsed CLI options for the headless orchestrator. * * Purpose: collect every flag and positional argument that influences how the * headless session runs (timeouts, output format, model, resume, supervision, * etc.) into a single typed bag so downstream logic doesn't re-parse argv. * * Consumer: parseHeadlessArgs and runHeadless in this module. */ export interface HeadlessOptions { timeout: number; json: boolean; outputFormat: OutputFormat; model?: string; command: string; commandExplicit?: boolean; commandArgs: string[]; context?: string; // file path or '-' for stdin contextText?: string; // inline text auto?: boolean; // chain into autonomous mode after milestone creation verbose?: boolean; // show tool calls in output maxRestarts?: number; // auto-restart on crash (default 3, 0 to disable) supervised?: boolean; // supervised mode: forward interactive requests to orchestrator responseTimeout?: number; // timeout for orchestrator response (default 30000ms) answers?: string; // path to answers JSON file eventFilter?: Set; // filter JSONL output to specific event types resumeSession?: string; // session ID to resume (--resume ) bare?: boolean; // --bare: suppress CLAUDE.md/AGENTS.md, user skills, project preferences } /** * Ensure the local .sf directory exists by creating a symlink to external * project state when the directory is missing. * * Purpose: let headless sessions recover when .sf/ is absent but an external * project state directory exists (e.g. after cloning or cache eviction), * avoiding a hard failure on every command that expects local state. * * Consumer: runHeadlessOnce during project-state validation. */ export function repairMissingSfSymlinkForHeadless( basePath: string, ): string | null { const sfDir = join(basePath, ".sf"); if (existsSync(sfDir)) return sfDir; const externalPath = externalSfRoot(basePath); if (!externalPath || !hasExternalProjectState(externalPath)) return null; const linkedPath = ensureSfSymlink(basePath); return existsSync(sfDir) ? (linkedPath ?? sfDir) : null; } /** * Wait until RPC extension commands are registered before submitting a slash command. * * Purpose: prevent headless `/sf ...` prompts from racing extension bootstrap and * falling through to the LLM as ordinary chat text. * * Consumer: runHeadlessOnce before sending the initial `/sf` command and the * follow-up autonomous command after headless milestone creation. */ export async function waitForHeadlessExtensionCommands( client: { getState(): Promise<{ extensionsReady?: boolean }> }, timeoutMs = 30_000, pollMs = 100, ): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const state = await client.getState(); if (state.extensionsReady) return; await new Promise((resolve) => setTimeout(resolve, pollMs)); } throw new Error( `Timed out after ${timeoutMs}ms waiting for extension commands to load`, ); } interface TrackedEvent { type: string; timestamp: number; detail?: string; } interface HeadlessUnitNotification { kind: "start" | "end"; unitType: string; unitId: string; verdict?: string; } /** * Parse a unit start/end notification line into a structured object. * * Purpose: turn free-form stderr notify lines like `[unit] slice M001/S01 starting` * into typed data so the trace collector and progress observers can react without * brittle string matching scattered across the file. * * Consumer: handleUnitStart, handleUnitEnd, and observeHeadlessNotification in * this module. */ export function parseHeadlessUnitNotification( message: string, ): HeadlessUnitNotification | null { const start = message.match(/\[unit\]\s+([\w-]+)\s+(\S+)\s+starting/); if (start) { return { kind: "start", unitType: start[1], unitId: start[2], }; } const end = message.match(/\[unit\]\s+([\w-]+)\s+(\S+)\s+ended\s*->\s*(\w+)/); if (end) { return { kind: "end", unitType: end[1], unitId: end[2], verdict: end[3], }; } return null; } // --------------------------------------------------------------------------- // Resume Session Resolution // --------------------------------------------------------------------------- /** * Result of resolving a session prefix to a concrete session. * * Purpose: represent the two possible outcomes of prefix lookup — a unique * matched session or an error string — so callers can branch cleanly without * throwing. * * Consumer: resolveResumeSession and the --resume flow in runHeadlessOnce. */ export interface ResumeSessionResult { session?: SessionInfo; error?: string; } /** * Resolve a session prefix to a single session. * Exact id match is preferred over prefix match. * Returns `{ session }` on unique match or `{ error }` on 0/ambiguous matches. * * Purpose: let users resume sessions with short prefixes (e.g. `--resume abc`) * while preventing accidental ambiguity when two IDs share a prefix. * * Consumer: runHeadlessOnce when processing the `--resume ` CLI flag. */ export function resolveResumeSession( sessions: SessionInfo[], prefix: string, ): ResumeSessionResult { // Exact match takes priority const exact = sessions.find((s) => s.id === prefix); if (exact) { return { session: exact }; } // Prefix match const matches = sessions.filter((s) => s.id.startsWith(prefix)); if (matches.length === 0) { return { error: `No session matching '${prefix}' found` }; } if (matches.length > 1) { const list = matches.map((s) => ` ${s.id}`).join("\n"); return { error: `Ambiguous session prefix '${prefix}' matches ${matches.length} sessions:\n${list}\n` + ` Try: use the full session ID, or run 'sf sessions' to list and select interactively`, }; } return { session: matches[0] }; } // --------------------------------------------------------------------------- // CLI Argument Parser // --------------------------------------------------------------------------- /** * Parse the process.argv array into structured HeadlessOptions. * * Purpose: centralise all CLI flag parsing for `sf headless` so the rest of * the orchestrator works with a typed options object instead of raw strings. * * Consumer: CLI entry point before invoking runHeadless. */ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { const options: HeadlessOptions = { timeout: 300_000, json: false, outputFormat: "text", command: "help", commandExplicit: false, commandArgs: [], }; const args = argv.slice(2); let commandSeen = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "headless") continue; if (arg.startsWith("--")) { if (arg === "--timeout" && i + 1 < args.length) { options.timeout = parseInt(args[++i], 10); if (Number.isNaN(options.timeout) || options.timeout < 0) { process.stderr.write( "[headless] Error: --timeout must be a non-negative integer (milliseconds, 0 to disable)\n", ); process.exit(1); } } else if (arg === "--json") { options.json = true; options.outputFormat = "stream-json"; } else if (arg === "--output-format" && i + 1 < args.length) { const fmt = args[++i]; if (!VALID_OUTPUT_FORMATS.has(fmt)) { process.stderr.write( `[headless] Error: --output-format must be one of: text, json, stream-json (got '${fmt}')\n`, ); process.exit(1); } options.outputFormat = fmt as OutputFormat; if (fmt === "stream-json" || fmt === "json") { options.json = true; } } else if (arg === "--model" && i + 1 < args.length) { // --model can also be passed from the main CLI; headless-specific takes precedence options.model = args[++i]; } else if (arg === "--context" && i + 1 < args.length) { options.context = args[++i]; } else if (arg === "--context-text" && i + 1 < args.length) { options.contextText = args[++i]; } else if (arg === "--auto") { options.auto = true; } else if (arg === "--verbose") { options.verbose = true; } else if (arg === "--max-restarts" && i + 1 < args.length) { options.maxRestarts = parseInt(args[++i], 10); if (Number.isNaN(options.maxRestarts) || options.maxRestarts < 0) { process.stderr.write( "[headless] Error: --max-restarts must be a non-negative integer\n", ); process.exit(1); } } else if (arg === "--answers" && i + 1 < args.length) { options.answers = args[++i]; } else if (arg === "--events" && i + 1 < args.length) { options.eventFilter = new Set(args[++i].split(",")); options.json = true; // --events implies --json if (options.outputFormat === "text") { options.outputFormat = "stream-json"; } } else if (arg === "--supervised") { options.supervised = true; options.json = true; // supervised implies json if (options.outputFormat === "text") { options.outputFormat = "stream-json"; } } else if (arg === "--no-supervised") { options.supervised = false; } else if (arg === "--response-timeout" && i + 1 < args.length) { options.responseTimeout = parseInt(args[++i], 10); if ( Number.isNaN(options.responseTimeout) || options.responseTimeout <= 0 ) { process.stderr.write( "[headless] Error: --response-timeout must be a positive integer (milliseconds)\n", ); process.exit(1); } } else if (arg === "--resume" && i + 1 < args.length) { options.resumeSession = args[++i]; } else if (arg === "--bare") { options.bare = true; } } else if (!commandSeen) { if (arg === "autonomous") { options.command = "autonomous"; options.auto = true; // autonomous subcommand implies --auto } else { options.command = arg; } options.commandExplicit = true; commandSeen = true; } else { options.commandArgs.push(arg); } } return options; } // --------------------------------------------------------------------------- // Reload sentinel — written by kill_agent so runHeadless can resume the session. // --------------------------------------------------------------------------- const RELOAD_SENTINEL = join(process.env.TEMP ?? "/tmp", "sf-reload-sentinel"); // --------------------------------------------------------------------------- // Main Orchestrator // --------------------------------------------------------------------------- /** * Run a headless session with automatic restart on crash and reload on * agent-requested resume. * * Purpose: provide a resilient outer loop around a single headless session so * transient RPC failures or agent-triggered restarts don't break CI pipelines * or long-running autonomous workflows. * * Consumer: CLI entry point after parseHeadlessArgs. */ export async function runHeadless(options: HeadlessOptions): Promise { const stdoutWithHandle = process.stdout as typeof process.stdout & { _handle?: { setBlocking?: (blocking: boolean) => void }; }; if (!process.stdout.isTTY) { stdoutWithHandle._handle?.setBlocking?.(true); } const maxRestarts = options.maxRestarts ?? 3; let restartCount = 0; while (true) { const result = await runHeadlessOnce(options, restartCount); // Success, blocked, interrupted, or operator-bounded timeout — exit normally. if ( !shouldRestartHeadlessRun({ ...result, restartCount, maxRestarts, }) ) { process.exit(result.exitCode); } // Agent requested reload — read session ID from sentinel and resume same session if (result.exitCode === EXIT_RELOAD) { if (existsSync(RELOAD_SENTINEL)) { try { const sessionId = readFileSync(RELOAD_SENTINEL, "utf-8").trim(); if (sessionId) { options.resumeSession = sessionId; process.stderr.write( `[headless] Reload requested — resuming session ${sessionId}\n`, ); unlinkSync(RELOAD_SENTINEL); // No backoff, no restart-count increment — straight back into the session continue; } } catch { // Fall through to normal restart if sentinel read fails } } // No sentinel or read failed — treat as normal restart process.stderr.write( "[headless] Reload: sentinel not found, starting fresh\n", ); } // Crash/error — check if we should restart if (restartCount >= maxRestarts) { process.stderr.write( `[headless] Max restarts (${maxRestarts}) reached. Exiting.\n`, ); process.exit(result.exitCode); } restartCount++; const backoffMs = Math.min(5000 * restartCount, 30_000); process.stderr.write( `[headless] Restarting in ${(backoffMs / 1000).toFixed(0)}s (attempt ${restartCount}/${maxRestarts})...\n`, ); await new Promise((resolve) => setTimeout(resolve, backoffMs)); } } async function runHeadlessOnce( options: HeadlessOptions, restartCount: number, ): Promise<{ exitCode: number; interrupted: boolean; timedOut: boolean }> { let interrupted = false; const startTime = Date.now(); const headlessRunId = `headless-${new Date(startTime).toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; const requestedCommand = options.command; const requestedCommandArgs = [...options.commandArgs]; if (options.command === "help") { const { printSubcommandHelp } = await import("./help-text.js"); printSubcommandHelp("headless", process.env.SF_VERSION || "0.0.0"); return { exitCode: EXIT_SUCCESS, interrupted: false, timedOut: false }; } if (options.command === "autonomous" && !options.resumeSession) { bootstrapProject(process.cwd()); if (!(await hasProjectMilestones(process.cwd()))) { if (!options.json) { process.stderr.write( "[headless] No milestones found; bootstrapping from repo docs and source inventory...\n", ); } options.command = "new-milestone"; options.auto = true; options.contextText = buildAutoBootstrapContext(process.cwd()); } } const isNewMilestone = options.command === "new-milestone"; const isInit = options.command === "init"; // new-milestone involves codebase investigation + artifact writing — needs more time if (isNewMilestone && options.timeout === 300_000) { options.timeout = 600_000; // 10 minutes } // auto-mode sessions are long-running (minutes to hours) with their own internal // per-unit timeout via auto-supervisor. Disable the overall timeout unless the // user explicitly set --timeout. const isAutoMode = options.command === "autonomous"; const wasRequestedAutoMode = requestedCommand === "autonomous"; // discuss and plan are multi-turn: they involve multiple question rounds, // codebase scanning, and artifact writing before the workflow completes (#3547). const isMultiTurnCommand = options.command === "autonomous" || options.command === "next" || options.command === "discuss" || options.command === "plan"; // Headless uses the same SF command flow as TUI, but it is unattended by // default: questions are answered by headless policy, not by waiting for a // separate orchestrator. Opt into external question forwarding with // --supervised when a caller really wants stdin/stdout mediation. if (options.command === "autonomous" && options.supervised === undefined) { options.supervised = false; } if (isAutoMode && options.timeout === 300_000) { options.timeout = 0; } // Supervised mode cannot share stdin with --context - if (options.supervised && options.context === "-") { process.stderr.write( "[headless] Error: --supervised cannot be used with --context - (both require stdin)\n", ); process.exit(1); } // Load answer injection file let injector: AnswerInjector | undefined; if (options.answers) { try { const answerFile = loadAndValidateAnswerFile(resolve(options.answers)); injector = new AnswerInjector(answerFile); if (!options.json) { process.stderr.write( `[headless] Loaded answer file: ${options.answers}\n`, ); } } catch (err) { process.stderr.write( formatStructuredError( error("Failed to load answer file", { operation: "loadAndValidateAnswerFile", file: resolve(options.answers ?? ""), guidance: "Validate the file is valid JSON matching the AnswerFile schema", cause: err, }), "[headless]", ), ); process.exit(1); } } // For new-milestone, load context and bootstrap .sf/ before spawning RPC child if (isNewMilestone) { if (!options.context && !options.contextText) { process.stderr.write( "[headless] Error: new-milestone requires --context or --context-text \n", ); process.exit(1); } let contextContent: string; try { contextContent = await loadContext(options); } catch (err) { process.stderr.write( formatStructuredError( error("Failed to load context for new-milestone", { operation: "loadContext", file: options.context === "-" ? "stdin" : resolve(options.context ?? ""), guidance: 'Use --context-text "..." for inline context, or verify the file path', cause: err, }), "[headless]", ), ); process.exit(1); } // Bootstrap .sf/ if needed const sfDir = join(process.cwd(), ".sf"); if (!existsSync(sfDir)) { if (!options.json) { process.stderr.write( "[headless] Bootstrapping .sf/ project structure...\n", ); } bootstrapProject(process.cwd()); } // Write context to temp file for the RPC child to read const runtimeDir = join(sfDir, "runtime"); mkdirSync(runtimeDir, { recursive: true }); writeFileSync( join(runtimeDir, "headless-context.md"), contextContent, "utf-8", ); } if (isInit) { if (!options.json) { process.stderr.write("[headless] Initializing SF project state...\n"); } bootstrapProject(process.cwd()); const initializedSfDir = join(process.cwd(), ".sf"); if (options.json) { process.stdout.write( JSON.stringify( { status: "initialized", sfDir: initializedSfDir, }, null, 2, ) + "\n", ); } else { process.stdout.write(`[headless] Initialized ${initializedSfDir}\n`); } return { exitCode: EXIT_SUCCESS, interrupted: false, timedOut: false }; } // Validate .sf/ directory (skip for new-milestone since we just bootstrapped it) const sfDir = join(process.cwd(), ".sf"); const legacyDir = join(process.cwd(), "." + ["g", "sd"].join("")); if (!isNewMilestone && !existsSync(sfDir)) { if (existsSync(legacyDir)) { renameSync(legacyDir, sfDir); process.stderr.write( "[headless] Migrated legacy project state to .sf/\n", ); } else if (repairMissingSfSymlinkForHeadless(process.cwd())) { if (!options.json) { process.stderr.write( "[headless] Re-linked .sf to existing external project state\n", ); } } else if (options.command === "autonomous" && options.commandExplicit) { if (!options.json) { process.stderr.write( "[headless] No .sf/ project state found; initializing for autonomous mode...\n", ); } bootstrapProject(process.cwd()); } else { process.stderr.write( formatStructuredError( error("No .sf/ directory found", { operation: "validateProjectState", file: process.cwd(), guidance: "'sf headless init' (non-interactive) or 'sf init' (interactive)", }), "[headless]", ), ); process.exit(1); } } // Query: read-only state snapshot, no RPC child needed if (options.command === "query") { const { handleQuery } = await import("./headless-query.js"); const result = await handleQuery(process.cwd()); return { exitCode: result.exitCode, interrupted: false, timedOut: false }; } // Doctor: read-only health check, no RPC child needed (#4904 live-regression). // The interactive `/sf doctor` command lives in the SF extension; this CLI // path lets non-interactive callers (CI, recovery scripts, the live-regression // suite) get the same diagnostic without a TTY. if (options.command === "doctor") { const wantsJson = options.json || options.commandArgs.includes("--json"); const wantsFix = options.commandArgs.includes("--fix"); const { runSFDoctor, formatDoctorReport, formatDoctorReportJson } = await import("./resources/extensions/sf/doctor.js"); let exitCode = 1; try { const report = await runSFDoctor(process.cwd(), { fix: wantsFix }); const out = wantsJson ? formatDoctorReportJson(report) : formatDoctorReport(report); process.stdout.write(`${out}\n`); exitCode = report.ok ? 0 : 1; } catch (err) { const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`[headless] doctor failed: ${msg}\n`); exitCode = 1; } // Bypass the auto-restart loop in runHeadless — doctor is a one-shot // diagnostic; exit 1 means "issues detected", not "crashed". process.exit(exitCode); } // Resolve CLI path for the child process const cliPath = process.env.SF_BIN_PATH || process.argv[1]; if (!cliPath) { process.stderr.write( "[headless] Error: Cannot determine CLI path. Set SF_BIN_PATH or run via sf.\n", ); process.exit(1); } // Create RPC client const clientOptions: Record = { cliPath, cwd: process.cwd(), }; if (options.model) { clientOptions.model = options.model; } if (injector) { clientOptions.env = injector.getSecretEnvVars(); } // Signal headless mode to the SF extension (skips UAT human pause, etc.) clientOptions.env = { ...((clientOptions.env as Record) || {}), SF_HEADLESS: "1", }; // Propagate --bare to the child process if (options.bare) { clientOptions.args = [ ...((clientOptions.args as string[]) || []), "--bare", ]; } const client = new RpcClient(clientOptions); // Event tracking let totalEvents = 0; let toolCallCount = 0; let blocked = false; let completed = false; let exitCode = 0; let milestoneReady = false; // tracks "Milestone X ready." for auto-chaining let timedOut = false; // true only when the overall timeout timer fires // Rolling buffer for milestone-ready detection across split streaming deltas. // Capped at 200 chars — long enough to bridge any realistic delta boundary. let milestoneDetectionBuffer = ""; let providerAutoResumePending = false; const recentEvents: TrackedEvent[] = []; const interactiveToolCallIds = new Set(); let lastVisibleProgressAt = Date.now(); let lastHeartbeatEventCount = 0; let lastHeartbeatToolCallCount = 0; let activeHeadlessUnit: string | undefined; let activeHeadlessModel: string | undefined; // JSON batch mode: cost aggregation (cumulative-max pattern per K004) let cumulativeCostUsd = 0; let cumulativeInputTokens = 0; let cumulativeOutputTokens = 0; let cumulativeCacheReadTokens = 0; let cumulativeCacheWriteTokens = 0; let lastSessionId: string | undefined; // Verbose text-mode state const toolStartTimes = new Map(); const openToolInfoByCallId = new Map< string, { toolName: string; args: unknown; startedAt: number } >(); let lastCostData: | { costUsd: number; inputTokens: number; outputTokens: number } | undefined; let thinkingBuffer = ""; let assistantTextBuffer = ""; const promptTraceSessionFiles = new Set(); // Drop only adjacent identical formatProgress output. A widget that // re-emits the same setStatus on every LLM call would otherwise print // the same line N times in a row. Two different lines still both show; // a run of identical ones collapses to one. let lastProgressLine: string | null = null; // Streaming state: tracks whether we're inside a text or thinking block let inTextBlock = false; let inThinkingBlock = false; // ─── Structured trace state ─────────────────────────────────────────────── // Lazy-init: traces only created for auto-mode and new-milestone+auto. // Uses maybeStartTrace() — not called upfront so we pay zero cost when disabled. const cwd = process.cwd(); let traceActive = false; // true once maybeStartTrace succeeded // Current unit span — tool spans are children of this let activeUnitSpan: ReturnType | null = null; // Map from tool call ID to its tool span (for matching start/end) const toolSpanByCallId = new Map>(); // Tracks pending tool_execution_start for which we haven't seen toolName yet const pendingToolSpans = new Map>(); /** Lazily initialize trace when entering auto-mode. Idempotent. */ function maybeStartTrace(sessionId?: string): void { if (traceActive) return; if (!isTraceEnabled()) return; const trace = initTraceCollector( cwd, sessionId ?? null, options.command ?? "run", options.model ?? null, ); if (trace) traceActive = true; } /** Flush the active trace to disk. Idempotent. */ function finalizeAndFlushTrace(): void { if (!traceActive) return; try { setTraceExitCode(exitCode); setTraceCost( cumulativeInputTokens, cumulativeOutputTokens, cumulativeCacheReadTokens, cumulativeCacheWriteTokens, cumulativeCostUsd, ); flushTrace(cwd); } catch { // Swallow trace flush errors — don't disrupt the main exit path } traceActive = false; activeUnitSpan = null; toolSpanByCallId.clear(); pendingToolSpans.clear(); } /** * Parse a unit notify message and start a unit span. * Matches: "[unit] milestone M001 starting" or "[unit] slice M001/S01 starting" etc. */ function handleUnitStart(message: string): void { const parsed = parseHeadlessUnitNotification(message); if (!parsed || parsed.kind !== "start") return; activeUnitSpan = startUnitSpan( parsed.unitType as "milestone" | "slice" | "task", parsed.unitId, ); } /** * Parse a unit end notify message and close the active unit span. * Matches: "[unit] milestone M001 ended -> ok" etc. */ function handleUnitEnd(message: string): void { const parsed = parseHeadlessUnitNotification(message); if (!parsed || parsed.kind !== "end") return; const unitId = parsed.unitId; const verdict = parsed.verdict ?? "error"; // Find the unit span by ID (may not be the top-of-stack if nested) if (!activeUnitSpan) return; if (activeUnitSpan.attributes.unitId !== unitId) return; const status = verdict === "ok" ? "ok" : verdict === "failed" ? "error" : verdict === "cancelled" ? "cancelled" : verdict === "timeout" ? "timeout" : "error"; if (status !== "ok") { traceEvent(activeUnitSpan, `unit-${status}`, { unitId, verdict }); traceError( activeUnitSpan, `Unit ${unitId} ended with verdict: ${verdict}`, ); } completeSpan(activeUnitSpan, status); activeUnitSpan = null; } /** * Handle tool_execution_start: create a tool span under the active unit (or root if no unit active). */ function handleToolStart(toolName: string, toolCallId: string): void { if (!traceActive) return; const parentSpan = activeUnitSpan ?? getActiveTrace()?.rootSpan; if (!parentSpan) return; const toolSpan = startToolSpan(parentSpan, toolName, toolCallId); toolSpanByCallId.set(toolCallId, toolSpan); } /** * Handle tool_execution_end: close the matching tool span. */ function handleToolEnd(toolCallId: string, isError: boolean): void { const span = toolSpanByCallId.get(toolCallId); if (!span) return; toolSpanByCallId.delete(toolCallId); if (isError) { traceError(span, "Tool execution failed"); } const durationMs = span.endTime && span.startTime ? span.endTime - span.startTime : undefined; if (durationMs !== undefined) { span.attributes.toolDurationMs = durationMs; } completeSpan(span, isError ? "error" : "ok"); } // Emit HeadlessJsonResult to stdout for --output-format json batch mode function emitBatchJsonResult(): void { if (options.outputFormat !== "json") return; const duration = Date.now() - startTime; const status: HeadlessJsonResult["status"] = blocked ? "blocked" : exitCode === EXIT_CANCELLED ? "cancelled" : exitCode === EXIT_ERROR ? timedOut ? "timeout" : "error" : "success"; const result: HeadlessJsonResult = { schemaVersion: 1, status, exitCode, sessionId: lastSessionId, duration, cost: { total: cumulativeCostUsd, input_tokens: cumulativeInputTokens, output_tokens: cumulativeOutputTokens, cache_read_tokens: cumulativeCacheReadTokens, cache_write_tokens: cumulativeCacheWriteTokens, }, toolCalls: toolCallCount, events: totalEvents, }; process.stdout.write(JSON.stringify(result) + "\n"); } function trackEvent(event: Record): void { totalEvents++; const type = String(event.type ?? "unknown"); if (type === "tool_execution_start") { toolCallCount++; } // Keep last 20 events for diagnostics const detail = type === "tool_execution_start" ? String(event.toolName ?? "") : type === "extension_ui_request" ? `${event.method}: ${event.title ?? event.message ?? ""}` : type === "extension_error" ? `${event.extensionPath ?? "unknown extension"} ${event.event ?? "unknown event"}` : undefined; 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(); } function observeHeadlessNotification(message: string): void { const unitStart = message.match(/\[unit\]\s+(\S+)\s+(\S+)\s+starting\b/); if (unitStart) { activeHeadlessUnit = `${unitStart[1]} ${unitStart[2]}`; } const unitEnd = message.match(/\[unit\]\s+(\S+)\s+(\S+)\s+ended\b/); if (unitEnd && activeHeadlessUnit === `${unitEnd[1]} ${unitEnd[2]}`) { activeHeadlessUnit = undefined; } const modelSelection = message.match( /^Model\s+\[[^\]]+\]\s+\[[^\]]+\]:\s+(.+)$/, ); if (modelSelection) { activeHeadlessModel = modelSelection[1]?.trim() || activeHeadlessModel; } const restoredModel = message.match(/Restored session model:\s+([^ ]+)/); if (restoredModel) { activeHeadlessModel = restoredModel[1]?.trim() || activeHeadlessModel; } } function formatOpenToolAge(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); const minutes = Math.floor(seconds / 60); const remainder = seconds % 60; if (minutes > 0) return `${minutes}m${remainder}s`; return `${remainder}s`; } function summarizeOpenHeadlessTools(now: number): string[] { const entries = [...openToolInfoByCallId.values()]; const shown = entries.slice(0, 3).map((info) => { const name = info.toolName || "unknown"; const args = summarizeToolArgs(info.toolName, info.args); const argPart = args ? `:${args.length > 48 ? `${args.slice(0, 45)}...` : args}` : ""; return `${name}${argPart} ${formatOpenToolAge(now - info.startedAt)}`; }); const hidden = entries.length - shown.length; if (hidden > 0) shown.push(`+${hidden} more`); return shown; } function resolvePromptTracePreviewChars(): number { const raw = process.env.SF_HEADLESS_PROMPT_TRACE_CHARS?.trim(); if (!raw) return 2400; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed < 0) return 2400; return Math.min(parsed, 12_000); } function findSessionPromptTrace( sessionFile: string, ): { customType: string; content: string } | null { let text = ""; try { text = readFileSync(sessionFile, "utf-8"); } catch { return null; } for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as Record; if (entry.type !== "custom_message") continue; const content = entry.content; if (typeof content !== "string" || !content.trim()) continue; return { customType: String(entry.customType ?? "custom"), content, }; } catch {} } return null; } function maybeWritePromptTrace(eventObj: Record): void { if (!options.verbose) return; if (process.env.SF_HEADLESS_PROMPT_TRACE === "0") return; const sessionFile = String(eventObj.sessionFile ?? ""); if (!sessionFile || promptTraceSessionFiles.has(sessionFile)) return; const trace = findSessionPromptTrace(sessionFile); if (!trace) return; promptTraceSessionFiles.add(sessionFile); for (const line of formatPromptTraceLines( trace.customType, trace.content, sessionFile, { maxChars: resolvePromptTracePreviewChars() }, )) { writeHeadlessLine(line); } } // Client started flag — replaces old stdinWriter null-check let clientStarted = false; // Adapter for AnswerInjector — wraps client.sendUIResponse in a writeToStdin-compatible callback // Initialized after client.start(); events won't fire before then let injectorStdinAdapter: (data: string) => void = () => {}; // Supervised mode state const pendingResponseTimers = new Map< string, ReturnType >(); let supervisedFallback = false; let stopSupervisedReader: (() => void) | null = null; const onStdinClose = () => { supervisedFallback = true; process.stderr.write( "[headless] Warning: orchestrator stdin closed, falling back to auto-response\n", ); }; if (options.supervised) { process.stdin.on("close", onStdinClose); } // Completion promise let resolveCompletion: () => void; const completionPromise = new Promise((resolve) => { resolveCompletion = resolve; }); // Idle timeout — three roles depending on command type: // - Quick commands (status, queue, …): genuine "are we done?" detector. // 15s after a tool call without further events = done. (IDLE_TIMEOUT_MS) // - new-milestone: bounded creative task; 120s buffer for LLM thinking // between bootstrap steps. (NEW_MILESTONE_IDLE_TIMEOUT_MS) // - Multi-turn (auto, next, discuss, plan): NOT a completion detector — // those signal done via "auto-mode stopped" terminal notifications, // and child-process exit catches crashes. The idle timer here is a // deadlock BACKSTOP only: 30 minutes, long enough to never misfire on // legitimate LLM reasoning, short enough to recover from a real hang. // (MULTI_TURN_DEADLOCK_BACKSTOP_MS) let idleTimer: ReturnType | null = null; const effectiveIdleTimeout = isNewMilestone ? NEW_MILESTONE_IDLE_TIMEOUT_MS : isMultiTurnCommand ? MULTI_TURN_DEADLOCK_BACKSTOP_MS : IDLE_TIMEOUT_MS; // Grace period after the last interactive tool ends before re-arming the // idle timer. Prevents the timer firing before the LLM has a chance to // process the tool response (e.g. a fast-returning interactive tool). const INTERACTIVE_TOOL_GRACE_MS = 500; let lastInteractiveToolEndTime = 0; function resetIdleTimer(): void { if (idleTimer) clearTimeout(idleTimer); const inGracePeriod = Date.now() - lastInteractiveToolEndTime < INTERACTIVE_TOOL_GRACE_MS; if ( !inGracePeriod && shouldArmHeadlessIdleTimeout(toolCallCount, interactiveToolCallIds.size) ) { idleTimer = setTimeout(() => { completed = true; resolveCompletion(); }, effectiveIdleTimeout); } } // Precompute supervised response timeout const responseTimeout = options.responseTimeout ?? 30_000; // Overall timeout (disabled when options.timeout === 0, e.g. auto-mode) const timeoutTimer = options.timeout > 0 ? setTimeout(() => { process.stderr.write( `[headless] Timeout after ${options.timeout / 1000}s\n`, ); timedOut = true; exitCode = EXIT_ERROR; 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]; process.stderr.write( formatHeadlessHeartbeat({ elapsedMs: now - startTime, quietMs, totalEvents, toolCallCount, eventDelta: totalEvents - lastHeartbeatEventCount, toolCallDelta: toolCallCount - lastHeartbeatToolCallCount, openToolCount: toolStartTimes.size, openToolDetails: summarizeOpenHeadlessTools(now), activeUnit: activeHeadlessUnit, activeModel: activeHeadlessModel, lastEventType: lastEvent?.type, lastEventDetail: lastEvent?.detail, }) + "\n", ); lastHeartbeatEventCount = totalEvents; lastHeartbeatToolCallCount = toolCallCount; }, HEADLESS_HEARTBEAT_INTERVAL_MS) : null; // Event handler client.onEvent((event) => { const eventObj = event as unknown as Record; trackEvent(eventObj); maybeWritePromptTrace(eventObj); const eventType = String(eventObj.type ?? ""); if (eventType === "tool_execution_start") { const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? ""); const toolName = String(eventObj.toolName ?? ""); if (toolCallId) { openToolInfoByCallId.set(toolCallId, { toolName, args: eventObj.args, startedAt: Date.now(), }); } if (toolCallId && isInteractiveHeadlessTool(toolName)) { interactiveToolCallIds.add(toolCallId); } // Lazy-start trace on first real tool call in auto-mode if (!traceActive && isAutoMode) { maybeStartTrace(lastSessionId); } // Start a tool span if tracing is active if (traceActive && toolCallId && toolName) { handleToolStart(toolName, toolCallId); } } else if (eventType === "tool_execution_update") { const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? ""); if (toolCallId) { const existing = openToolInfoByCallId.get(toolCallId); openToolInfoByCallId.set(toolCallId, { toolName: String(eventObj.toolName ?? existing?.toolName ?? ""), args: eventObj.args ?? existing?.args, startedAt: existing?.startedAt ?? Date.now(), }); } } else if (eventType === "tool_execution_end") { const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? ""); if (toolCallId) { if (interactiveToolCallIds.has(toolCallId)) { lastInteractiveToolEndTime = Date.now(); } interactiveToolCallIds.delete(toolCallId); openToolInfoByCallId.delete(toolCallId); } // Close the tool span if tracing is active if (traceActive && toolCallId) { const isError = eventObj.isError === true || eventObj.error != null; handleToolEnd(toolCallId, isError); } } resetIdleTimer(); // Answer injector: observe events for question metadata injector?.observeEvent(eventObj); // --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") { if (!options.eventFilter || options.eventFilter.has(eventType)) { process.stdout.write(JSON.stringify(eventObj) + "\n"); } } else if (options.outputFormat === "json") { // Batch mode: silently track cost_update events (cumulative-max per K004) const eventType = String(eventObj.type ?? ""); if (eventType === "cost_update") { const data = eventObj as Record; const cumCost = data.cumulativeCost as | Record | undefined; if (cumCost) { cumulativeCostUsd = Math.max( cumulativeCostUsd, Number(cumCost.costUsd ?? 0), ); const tokens = data.tokens as Record | undefined; if (tokens) { cumulativeInputTokens = Math.max( cumulativeInputTokens, tokens.input ?? 0, ); cumulativeOutputTokens = Math.max( cumulativeOutputTokens, tokens.output ?? 0, ); cumulativeCacheReadTokens = Math.max( cumulativeCacheReadTokens, tokens.cacheRead ?? 0, ); cumulativeCacheWriteTokens = Math.max( cumulativeCacheWriteTokens, tokens.cacheWrite ?? 0, ); } } } // Track sessionId from init_result if (eventType === "init_result") { lastSessionId = String( (eventObj as Record).sessionId ?? "", ); // Write to session-id file so kill_agent can read it before exit if (lastSessionId) { const sessionIdFile = join( process.env.TEMP ?? "/tmp", "sf-current-session", ); try { writeFileSync(sessionIdFile, lastSessionId, "utf-8"); } catch { // non-fatal } } } } else if (!options.json) { // Progress output to stderr with verbose state tracking const eventType = String(eventObj.type ?? ""); // Track cost_update events for agent_end summary if (eventType === "cost_update") { const data = eventObj as Record; const cumCost = data.cumulativeCost as | Record | undefined; if (cumCost) { const tokens = data.tokens as Record | undefined; lastCostData = { costUsd: Number(cumCost.costUsd ?? 0), inputTokens: tokens?.input ?? 0, outputTokens: tokens?.output ?? 0, }; if (process.env.PI_TOKEN_TELEMETRY === "1") { process.stderr.write( `[PI_TOKEN] input=${tokens?.input ?? 0} output=${tokens?.output ?? 0} cache_read=${tokens?.cacheRead ?? 0} cache_write=${tokens?.cacheWrite ?? 0} cost=$${Number(cumCost.costUsd ?? 0).toFixed(4)}\n`, ); } } } // Stream assistant text and thinking deltas in verbose mode if (eventType === "message_update") { const ame = eventObj.assistantMessageEvent as | Record | undefined; // Milestone-ready detection: only on text_delta events (exclude tool // output and verbose log lines that could spuriously match the pattern). // Accumulate a rolling 200-char buffer so patterns split across two // consecutive deltas are still detected. if (isNewMilestone && options.auto && ame?.type === "text_delta") { const deltaText = String(ame?.delta ?? ame?.text ?? ""); if (deltaText) { milestoneDetectionBuffer = ( milestoneDetectionBuffer + deltaText ).slice(-200); milestoneReady ||= isMilestoneReadyText(milestoneDetectionBuffer); } } if (ame && options.verbose) { const ameType = String(ame.type ?? ""); // --- Text streaming --- if (ameType === "text_start") { inTextBlock = true; process.stderr.write(formatTextStart()); lastVisibleProgressAt = Date.now(); } else if (ameType === "text_delta") { const delta = String(ame.delta ?? ame.text ?? ""); if (delta) { if (!inTextBlock) { // Edge case: delta without start inTextBlock = true; process.stderr.write(formatTextStart()); } process.stderr.write(delta); lastVisibleProgressAt = Date.now(); } } else if (ameType === "text_end") { if (inTextBlock) { process.stderr.write(formatTextEnd() + "\n"); lastVisibleProgressAt = Date.now(); inTextBlock = false; } } // --- Thinking streaming --- else if (ameType === "thinking_start") { inThinkingBlock = true; process.stderr.write(formatThinkingStart()); lastVisibleProgressAt = Date.now(); } else if (ameType === "thinking_delta") { const delta = String(ame.delta ?? ame.text ?? ""); if (delta) { if (!inThinkingBlock) { inThinkingBlock = true; process.stderr.write(formatThinkingStart()); } process.stderr.write(delta); lastVisibleProgressAt = Date.now(); } } else if (ameType === "thinking_end") { if (inThinkingBlock) { process.stderr.write(formatThinkingEnd() + "\n"); lastVisibleProgressAt = Date.now(); inThinkingBlock = false; } } } // Non-verbose: accumulate separated thinking/text previews for // truncated one-liners before tool calls and message end. else { const previewDelta = extractAssistantPreviewDelta(ame); if (previewDelta?.kind === "text") { assistantTextBuffer += previewDelta.text; } else if (previewDelta?.kind === "thinking") { thinkingBuffer += previewDelta.text; } } } // Track tool execution start timestamps if (eventType === "tool_execution_start") { const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? ""); if (toolCallId) toolStartTimes.set(toolCallId, Date.now()); } // Close any open streaming blocks before tool calls or message end if ( options.verbose && (eventType === "tool_execution_start" || eventType === "message_end") ) { if (inTextBlock) { process.stderr.write("\n"); inTextBlock = false; } if (inThinkingBlock) { process.stderr.write("\n"); inThinkingBlock = false; } } // Non-verbose: flush accumulated buffers as truncated one-liners else if ( !options.verbose && (eventType === "tool_execution_start" || eventType === "message_end") ) { if (assistantTextBuffer.trim()) { writeHeadlessLine(formatTextLine(assistantTextBuffer)); assistantTextBuffer = ""; } if (thinkingBuffer.trim()) { writeHeadlessLine(formatThinkingLine(thinkingBuffer)); thinkingBuffer = ""; } } // Compute tool duration for tool_execution_end let toolDuration: number | undefined; let isToolError = false; if (eventType === "tool_execution_end") { const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? ""); const startTime = toolStartTimes.get(toolCallId); if (startTime) { toolDuration = Date.now() - startTime; toolStartTimes.delete(toolCallId); } isToolError = eventObj.isError === true || eventObj.error != null; } const ctx: ProgressContext = { verbose: !!options.verbose, toolDuration, isError: isToolError, lastCost: eventType === "agent_end" ? lastCostData : undefined, }; const line = formatProgress(eventObj, ctx); if (line && line !== lastProgressLine) { writeHeadlessLine(line); lastProgressLine = line; } } if (eventType === "extension_error" && !completed) { exitCode = EXIT_ERROR; completed = true; resolveCompletion(); return; } // Handle execution_complete (v2 structured completion) // Skip for multi-turn commands (auto, next) — their completion is detected via // isTerminalNotification("Auto-mode stopped..."/"Step-mode stopped..."), not per-turn events if ( eventObj.type === "execution_complete" && !completed && !isMultiTurnCommand ) { completed = true; const status = String(eventObj.status ?? "success"); exitCode = mapStatusToExitCode(status); if (eventObj.status === "blocked") blocked = true; resolveCompletion(); return; } // Handle extension_ui_request if (eventObj.type === "extension_ui_request" && clientStarted) { const waitForProviderAutoResume = providerAutoResumePending && isPauseNotification(eventObj); if (isAutoResumeScheduledNotification(eventObj)) { providerAutoResumePending = true; } // Check for terminal notification before auto-responding if (isBlockedNotification(eventObj) && !waitForProviderAutoResume) { blocked = true; } // Detect "Milestone X ready." for auto-mode chaining if (isMilestoneReadyNotification(eventObj)) { milestoneReady = true; } if (isTerminalNotification(eventObj) && !waitForProviderAutoResume) { completed = true; } // Structured trace: handle unit start/end notify messages if (eventObj.method === "notify") { const message = String(eventObj.message ?? ""); observeHeadlessNotification(message); if ( message.includes("Auto-mode resumed") || message.includes("Step-mode resumed") || (message.includes("[unit]") && message.includes("starting")) ) { providerAutoResumePending = false; } if (traceActive) { if (message.includes("[unit]") && message.includes("starting")) { handleUnitStart(message); } else if (message.includes("[unit]") && message.includes("ended")) { handleUnitEnd(message); } } } // Answer injection: try to handle with pre-supplied answers before supervised/auto if ( injector && !FIRE_AND_FORGET_METHODS.has(String(eventObj.method ?? "")) ) { if (injector.tryHandle(eventObj, injectorStdinAdapter)) { if (completed) { exitCode = blocked ? EXIT_BLOCKED : EXIT_SUCCESS; resolveCompletion(); } return; } } const method = String(eventObj.method ?? ""); const shouldSupervise = options.supervised && !supervisedFallback && !FIRE_AND_FORGET_METHODS.has(method); if (shouldSupervise) { // Interactive request in supervised mode — let orchestrator respond const eventId = String(eventObj.id ?? ""); const timer = setTimeout(() => { pendingResponseTimers.delete(eventId); handleExtensionUIRequest( eventObj as unknown as ExtensionUIRequest, client, ); process.stdout.write( JSON.stringify({ type: "supervised_timeout", id: eventId, method, }) + "\n", ); }, responseTimeout); pendingResponseTimers.set(eventId, timer); } else { handleExtensionUIRequest( eventObj as unknown as ExtensionUIRequest, client, ); } // If we detected a terminal notification, resolve after responding if (completed) { exitCode = blocked ? EXIT_BLOCKED : EXIT_SUCCESS; resolveCompletion(); return; } } // Quick commands: resolve on first agent_end if ( eventObj.type === "agent_end" && isQuickCommand(options.command) && !completed ) { completed = true; resolveCompletion(); return; } // Long-running commands: agent_end after tool execution — possible completion // The idle timer + terminal notification handle this case. }); // Signal handling const signalHandler = () => { process.stderr.write( "\n[headless] Interrupted, stopping child process...\n", ); interrupted = true; exitCode = EXIT_CANCELLED; // Kill child process — don't await, just fire and exit. // The main flow may be awaiting a promise that resolves when the child dies, // which would race with this handler. Exit synchronously to ensure correct exit code. // Log stop failures to stderr for head/headless parity — interactive-mode logs its // stop errors too. Exit code is already forced via process.exit, so logging is // purely observability and doesn't change shutdown semantics. try { client.stop().catch((err: unknown) => { process.stderr.write( `[headless] client.stop() rejected: ${err instanceof Error ? err.message : String(err)}\n`, ); }); } catch (err) { process.stderr.write( `[headless] client.stop() threw: ${err instanceof Error ? err.message : String(err)}\n`, ); } if (timeoutTimer) clearTimeout(timeoutTimer); if (idleTimer) clearTimeout(idleTimer); if (heartbeatTimer) clearInterval(heartbeatTimer); // Emit batch JSON result if in json mode before exiting if (options.outputFormat === "json") { emitBatchJsonResult(); } finalizeAndFlushTrace(); process.exit(exitCode); }; process.on("SIGINT", signalHandler); process.on("SIGTERM", signalHandler); // Start the RPC session try { await client.start(); } catch (err) { process.stderr.write( formatStructuredError( error("Failed to start RPC session", { operation: "RpcClient.start", file: cliPath, guidance: "Verify SF_BIN_PATH is set or reinstall singularity-forge", cause: err, }), "[headless]", ), ); if (timeoutTimer) clearTimeout(timeoutTimer); if (heartbeatTimer) clearInterval(heartbeatTimer); process.exit(1); } // v2 protocol negotiation — attempt init for structured completion events let _v2Enabled = false; try { await client.init({ clientId: "sf-headless" }); _v2Enabled = true; } catch { process.stderr.write( "[headless] Warning: v2 init failed, falling back to v1 string-matching\n", ); } clientStarted = true; // --resume: resolve session ID and switch to it if (options.resumeSession) { const projectSessionsDir = getProjectSessionsDir(process.cwd()); const sessions = await SessionManager.list( process.cwd(), projectSessionsDir, ); const result = resolveResumeSession(sessions, options.resumeSession); if (result.error) { process.stderr.write( formatStructuredError( error(result.error, { operation: "resolveResumeSession", guidance: "Use the full session ID, or run 'sf sessions' to list and select interactively", }), "[headless]", ), ); await client.stop(); if (timeoutTimer) clearTimeout(timeoutTimer); process.exit(1); } const matched = result.session!; const switchResult = await client.switchSession(matched.path); if (switchResult.cancelled) { process.stderr.write( formatStructuredError( error(`Session switch to '${matched.id}' was cancelled`, { operation: "switchSession", file: matched.path, guidance: "Check extension logs or disable the cancelling extension", }), "[headless]", ), ); await client.stop(); if (timeoutTimer) clearTimeout(timeoutTimer); process.exit(1); } process.stderr.write(`[headless] Resuming session ${matched.id}\n`); } // Build injector adapter — wraps client.sendUIResponse for AnswerInjector's writeToStdin interface injectorStdinAdapter = (data: string) => { try { const parsed = JSON.parse(data.trim()); if (parsed.type === "extension_ui_response" && parsed.id) { const { id, value, values, confirmed, cancelled } = parsed; client.sendUIResponse(id, { value, values, confirmed, cancelled }); } } catch { process.stderr.write( "[headless] Warning: injector adapter received unparseable data\n", ); } }; // Start supervised stdin reader for orchestrator commands if (options.supervised) { stopSupervisedReader = startSupervisedStdinReader(client, (id) => { const timer = pendingResponseTimers.get(id); if (timer) { clearTimeout(timer); pendingResponseTimers.delete(id); } }); // Ensure stdin is in flowing mode for JSONL reading process.stdin.resume(); } // Detect child process crash (read-only exit event subscription — not stdin access) const internalProcess = (client as any).process as ChildProcess; if (internalProcess) { internalProcess.on("exit", (code) => { if (!completed) { const msg = `[headless] Child process exited unexpectedly with code ${code ?? "null"}\n`; process.stderr.write(msg); exitCode = EXIT_ERROR; resolveCompletion(); } }); } if (!options.json) { writeHeadlessLine( `[headless] Running /sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}...`, ); } // Send the command const command = `/sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}`; try { await waitForHeadlessExtensionCommands(client); await client.prompt(command); } catch (err) { process.stderr.write( `[headless] Error: Failed to send prompt: ${err instanceof Error ? err.message : String(err)}\n`, ); exitCode = EXIT_ERROR; } // Wait for completion if (exitCode === EXIT_SUCCESS || exitCode === EXIT_BLOCKED) { await completionPromise; } // Autonomous-mode chaining: if --auto and milestone creation succeeded, // send the canonical autonomous command. if ( isNewMilestone && options.auto && milestoneReady && !blocked && exitCode === EXIT_SUCCESS ) { if (!options.json) { process.stderr.write( "[headless] Milestone ready — chaining into autonomous mode...\n", ); } // Reset completion state for the auto-mode phase. // Disable the overall timeout — auto-mode has its own internal supervisor. if (timeoutTimer) clearTimeout(timeoutTimer); completed = false; milestoneReady = false; blocked = false; const autoCompletionPromise = new Promise((resolve) => { resolveCompletion = resolve; }); try { await waitForHeadlessExtensionCommands(client); await client.prompt("/sf autonomous"); } catch (err) { process.stderr.write( `[headless] Error: Failed to start autonomous mode: ${err instanceof Error ? err.message : String(err)}\n`, ); exitCode = EXIT_ERROR; } if (exitCode === EXIT_SUCCESS || exitCode === EXIT_BLOCKED) { await autoCompletionPromise; } } // Cleanup if (timeoutTimer) clearTimeout(timeoutTimer); if (idleTimer) clearTimeout(idleTimer); if (heartbeatTimer) clearInterval(heartbeatTimer); for (const timer of pendingResponseTimers.values()) clearTimeout(timer); pendingResponseTimers.clear(); stopSupervisedReader?.(); process.stdin.removeListener("close", onStdinClose); process.removeListener("SIGINT", signalHandler); process.removeListener("SIGTERM", signalHandler); // Flush any active trace before stopping the client finalizeAndFlushTrace(); await client.stop(); const solverEvalRecord = (isAutoMode || wasRequestedAutoMode) && timedOut ? await runHeadlessTimeoutSolverEval(process.cwd()) : null; // Summary const duration = ((Date.now() - startTime) / 1000).toFixed(1); const status = blocked ? "blocked" : exitCode === EXIT_CANCELLED ? "cancelled" : exitCode === EXIT_ERROR ? timedOut ? "timeout" : "error" : "complete"; const durationMs = Date.now() - startTime; await recordHeadlessRunBestEffort(process.cwd(), { runId: headlessRunId, command: `/sf ${requestedCommand}${requestedCommandArgs.length > 0 ? " " + requestedCommandArgs.join(" ") : ""}`, status, exitCode, timedOut, interrupted, restartCount, maxRestarts: options.maxRestarts ?? 3, durationMs, totalEvents, toolCalls: toolCallCount, solverEvalRunId: solverEvalRecord?.runId ?? null, solverEvalReportPath: solverEvalRecord?.reportPath ?? null, details: { effectiveCommand: `/sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}`, outputFormat: options.outputFormat, eventFilter: options.eventFilter ? [...options.eventFilter] : [], solverEvalDbRecorded: solverEvalRecord?.dbRecorded ?? null, }, }); process.stderr.write(`[headless] Status: ${status}\n`); process.stderr.write(`[headless] Duration: ${duration}s\n`); process.stderr.write( `[headless] Events: ${totalEvents} total, ${toolCallCount} tool calls\n`, ); if (options.eventFilter) { process.stderr.write( `[headless] Event filter: ${[...options.eventFilter].join(", ")}\n`, ); } if (restartCount > 0) { process.stderr.write(`[headless] Restarts: ${restartCount}\n`); } // Answer injection stats if (injector) { const stats = injector.getStats(); process.stderr.write( `[headless] Answers: ${stats.questionsAnswered} answered, ${stats.questionsDefaulted} defaulted, ${stats.secretsProvided} secrets\n`, ); for (const warning of injector.getUnusedWarnings()) { process.stderr.write(`${warning}\n`); } } // On failure, print last 5 events for diagnostics if (exitCode !== 0) { const lastFive = recentEvents.slice(-5); if (lastFive.length > 0) { process.stderr.write("[headless] Last events:\n"); for (const e of lastFive) { process.stderr.write(` ${e.type}${e.detail ? `: ${e.detail}` : ""}\n`); } } } // Emit structured JSON result in batch mode emitBatchJsonResult(); return { exitCode, interrupted, timedOut }; }