singularity-forge/src/headless.ts
Mikael Hugo 365c6bbc3b
Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions
chore: formatter / linter touch-up (230 files)
Pure formatting / lint-fix pass that ran during `npm run build:core`
in the session that landed the agent-runner / quota / coverage /
phase-2 routing work. No logic changes — indentation, trailing
commas, import sort, etc. Captured separately so the actual feature
commits stay scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:19:53 +02:00

2283 lines
74 KiB
TypeScript

/**
* 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 <subcommand>`.
*/
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import type { SessionInfo } from "@singularity-forge/coding-agent";
import { RpcClient, SessionManager } from "@singularity-forge/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 {
classifyUnexpectedChildExit,
EXIT_BLOCKED,
EXIT_CANCELLED,
EXIT_ERROR,
EXIT_RELOAD,
EXIT_SUCCESS,
FIRE_AND_FORGET_METHODS,
IDLE_TIMEOUT_MS,
isBlockedNotification,
isInteractiveHeadlessTool,
isMilestoneReadyNotification,
isMilestoneReadyText,
isPauseNotification,
isQuickCommand,
isScheduledResumeNotification,
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 {
findUnsupportedAutonomousArgs,
formatUnsupportedAutonomousArgs,
} from "./resources/extensions/sf/autonomous-command-args.js";
import {
checkPddFields,
formatPddRefusal,
} from "./resources/extensions/sf/headless-pdd-check.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<HeadlessTimeoutSolverEvalRecord | null> {
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<string, unknown>,
): Promise<void> {
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
chainAutonomous?: boolean; // chain into autonomous mode after milestone creation
verbose?: boolean; // show tool calls in output
maxRestarts?: number; // automatic 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<string>; // filter JSONL output to specific event types
resumeSession?: string; // session ID to resume (--resume <id>)
bare?: boolean; // --bare: suppress CLAUDE.md/AGENTS.md, user skills, project preferences
yolo?: boolean; // --yolo / -y: activate YOLO mode (build+autonomous+deep+unrestricted)
skipPddCheck?: boolean; // --skip-pdd-check: bypass ADR-0000 PDD-fields gate for new-milestone (migration escape hatch)
}
/**
* 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<void> {
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 <prefix>` 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 === "-y") {
options.yolo = true;
} else 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 === "--autonomous") {
options.chainAutonomous = true;
} else if (arg === "--auto") {
process.stderr.write(
"[headless] Error: --auto was removed. Use --autonomous to chain into autonomous mode.\n",
);
process.exit(1);
} 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 (arg === "--yolo") {
options.yolo = true;
} else if (arg === "--skip-pdd-check") {
options.skipPddCheck = true;
} else if (commandSeen) {
options.commandArgs.push(arg);
}
} else if (!commandSeen) {
if (arg === "autonomous" || arg === "auto") {
options.command = "autonomous";
options.chainAutonomous = true;
} 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<void> {
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 === "autonomous") {
const unsupportedArgs = findUnsupportedAutonomousArgs(options.commandArgs);
if (unsupportedArgs.length > 0) {
process.stderr.write(
`[headless] ${formatUnsupportedAutonomousArgs(unsupportedArgs)}\n`,
);
if (options.outputFormat === "json") {
const result: HeadlessJsonResult = {
schemaVersion: 1,
status: "error",
exitCode: EXIT_ERROR,
duration: Date.now() - startTime,
cost: {
total: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
toolCalls: 0,
events: 0,
};
process.stdout.write(`${JSON.stringify(result)}\n`);
}
return {
exitCode: EXIT_ERROR,
interrupted: false,
timedOut: false,
};
}
}
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.chainAutonomous = true;
options.contextText = buildAutoBootstrapContext(process.cwd());
} else if (!options.commandArgs.includes("--skip-triage")) {
// Auto-drain the self-feedback triage queue before entering the
// autonomous dispatch loop. Without this, items in
// .sf/self-feedback.jsonl sit unprocessed and SF can only work on
// the active milestone — defeating the self-heal thesis.
// Comment on headless-triage at line 917-921 acknowledges that
// autonomous-loop followUp delivery was unreliable (sf-mp4rxkwb-l4baga),
// hence the deterministic operator path. This wires the deterministic
// path BEFORE the dispatch loop so autonomous == triage-then-dispatch.
// Skipped when resuming (resumeSession check above) or when the user
// passes --skip-triage to opt out (e.g. to debug a specific milestone
// without backlog churn).
try {
const { handleTriage } = await import("./headless-triage.js");
if (!options.json) {
process.stderr.write(
"[headless] autonomous: draining self-feedback triage queue first...\n",
);
}
await handleTriage(process.cwd(), {
apply: true,
json: !!options.json,
max: 5, // bound the up-front cost; remainder flushes on next session_start
});
} catch (err) {
// Triage failure must not block autonomous mode — the loop's own
// dispatch will keep going; backlog will just stay until next run.
if (!options.json) {
process.stderr.write(
`[headless] autonomous: triage drain failed (non-fatal): ${
err instanceof Error ? err.message : String(err)
}\n`,
);
}
}
}
}
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
}
// autonomous 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 isAutonomousCommand = options.command === "autonomous";
const wasRequestedAutonomousCommand =
requestedCommand === "autonomous" || requestedCommand === "auto";
// 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 (isAutonomousCommand && 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 <file> or --context-text <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);
}
// ADR-0000 purpose gate: refuse to proceed unless the seed context
// names the eight PDD fields (or at minimum the spine: Purpose,
// Consumer, Contract, plus Evidence-or-Falsifier). Skipped when SF
// generated the context itself (the autonomous→new-milestone
// auto-bootstrap path supplies an internally-built seed that is not
// meant to be PDD-shaped) and when the operator explicitly opted out
// with --skip-pdd-check (migration escape hatch).
const isOperatorInitiatedNewMilestone =
requestedCommand === "new-milestone";
if (isOperatorInitiatedNewMilestone) {
if (options.skipPddCheck) {
process.stderr.write(
"[headless] WARNING: --skip-pdd-check active — ADR-0000 PDD gate bypassed. " +
"This escape hatch exists for milestones that pre-date the check. " +
"New seed docs should name all 8 PDD fields (Purpose, Consumer, Contract, " +
"Failure boundary, Evidence, Non-goals, Invariants, Assumptions).\n",
);
} else {
const pddReport = checkPddFields(contextContent);
if (!pddReport.ok) {
process.stderr.write(`${formatPddRefusal(pddReport)}\n`);
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");
if (!isNewMilestone && !existsSync(sfDir)) {
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.
// ARCHITECTURE NOTE: this intentionally bypasses the SF extension dispatcher
// for performance — no child process, direct DB read. If /query gains new
// behaviour in the extension, mirror it here in headless-query.ts.
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 };
}
// Out-of-band slice/milestone status updates. Bypass the RPC child
// because they're deterministic single-row DB UPDATEs — no agent
// needed. See headless-mark-state.ts for the why.
if (
options.command === "complete-slice" ||
options.command === "skip-slice" ||
options.command === "complete-milestone"
) {
const { handleMarkState } = await import("./headless-mark-state.js");
const result = await handleMarkState(process.cwd(), {
command: options.command,
args: options.commandArgs,
json: options.json,
});
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}
// Operator self-feedback CLI: add/list/resolve self_feedback rows
// without going through SQL. Same RPC-child-bypass pattern.
if (options.command === "feedback") {
const sub = options.commandArgs[0];
if (sub !== "add" && sub !== "list" && sub !== "resolve") {
process.stderr.write(
"[headless] Error: feedback subcommand must be one of: add, list, resolve\n",
);
return { exitCode: 2, interrupted: false, timedOut: false };
}
const { handleFeedback } = await import("./headless-feedback.js");
const result = await handleFeedback(process.cwd(), {
subcommand: sub,
args: options.commandArgs.slice(1),
json: options.json,
});
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}
// UOK gate health: `sf headless status uok [--json]`
// Bypasses the RPC path for instant, TTY-independent gate health output.
if (options.command === "status" && options.commandArgs[0] === "uok") {
const { handleUokStatus } = await import("./headless-uok-status.js");
const wantsJson = options.json || options.commandArgs.includes("--json");
const result = await handleUokStatus(process.cwd(), { json: wantsJson });
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}
// Generic headless status: read-only project snapshot. This deliberately
// bypasses the interactive RPC/v2 path because `/status` opens a TUI overlay
// in interactive mode and can hang waiting for protocol init in headless.
if (options.command === "status") {
const { handleHeadlessStatus } = await import("./headless-status.js");
const wantsJson =
options.json ||
options.outputFormat === "json" ||
options.outputFormat === "stream-json" ||
options.commandArgs.includes("--json");
const result = await handleHeadlessStatus(process.cwd(), {
json: wantsJson,
});
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}
// Reflect: assemble the SF reflection corpus snapshot (open + recent
// self-feedback, recent commits, milestone state, validation files,
// prior report) and emit either the rendered prompt brief (default) or
// the raw corpus JSON (--json). Operator-driven — the autonomous-loop
// reflection unit is a separate follow-up.
// Triage: deterministic operator path to drain the self-feedback queue
// without relying on the autonomous-loop followUp delivery (which gets
// swallowed when the loop bails at validation before any turn runs —
// sf-mp4rxkwb-l4baga). Outputs the canonical triage prompt that can be
// piped into any model, or a structured candidate list with --json/--list.
if (options.command === "triage") {
const wantsJson = options.json || options.commandArgs.includes("--json");
const wantsList = options.commandArgs.includes("--list");
const wantsRun = options.commandArgs.includes("--run");
const wantsApply = options.commandArgs.includes("--apply");
const maxIdx = options.commandArgs.indexOf("--max");
let max: number | undefined;
if (maxIdx >= 0 && maxIdx + 1 < options.commandArgs.length) {
const n = Number.parseInt(options.commandArgs[maxIdx + 1] ?? "", 10);
if (Number.isFinite(n) && n > 0) max = n;
}
const modelIdx = options.commandArgs.indexOf("--model");
const model =
modelIdx >= 0 && modelIdx + 1 < options.commandArgs.length
? options.commandArgs[modelIdx + 1]
: undefined;
const { handleTriage } = await import("./headless-triage.js");
const result = await handleTriage(process.cwd(), {
json: wantsJson,
list: wantsList,
max,
run: wantsRun,
apply: wantsApply,
model,
});
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}
if (options.command === "reflect") {
const wantsJson = options.json || options.commandArgs.includes("--json");
const wantsRun = options.commandArgs.includes("--run");
const modelIdx = options.commandArgs.indexOf("--model");
const model =
modelIdx >= 0 && modelIdx + 1 < options.commandArgs.length
? options.commandArgs[modelIdx + 1]
: undefined;
const { handleReflect } = await import("./headless-reflect.js");
const result = await handleReflect(process.cwd(), {
json: wantsJson,
run: wantsRun,
model,
});
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}
// Usage: gemini-cli account snapshot (tier, project, per-model quota), no
// RPC child needed. Uses snapshotGeminiCliAccount from the
// @singularity-forge/google-gemini-cli-provider package directly.
if (options.command === "usage") {
const wantsJson = options.json || options.commandArgs.includes("--json");
const { handleUsage } = await import("./headless-usage.js");
const result = await handleUsage(process.cwd(), { json: wantsJson });
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
}
// Doctor: read-only health check, no RPC child needed (#4904 live-regression).
// ARCHITECTURE NOTE: this intentionally bypasses the SF extension dispatcher
// for performance and TTY-independence. The interactive `/doctor` command in
// the extension calls the same runSFDoctor() engine function — keep them in
// sync if doctor.js gains new capabilities.
// 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 automatic restart loop in runHeadless — doctor is a one-shot
// diagnostic; exit 1 means "issues detected", not "crashed".
process.exit(exitCode);
}
// import-backlog: deterministic text→DB transform, no LLM or RPC child needed.
if (options.command === "import-backlog") {
const { runImportBacklog } = await import("./headless-import-backlog.js");
const filePath = options.commandArgs[0] ?? options.context;
if (!filePath) {
process.stderr.write(
"[headless] Error: import-backlog requires a file path as the first argument\n" +
" Usage: sf headless import-backlog <file.md>\n",
);
process.exit(1);
}
const exitCode = await runImportBacklog(resolve(filePath), process.cwd(), {
json: options.json,
});
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<string, unknown> = {
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<string, string>) || {}),
SF_HEADLESS: "1",
...(options.yolo ? { SF_YOLO: "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 autonomous 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<string>();
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<string, number>();
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<string>();
// 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 autonomous mode and new-milestone+autonomous.
// 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<typeof startUnitSpan> | null = null;
// Map from tool call ID to its tool span (for matching start/end)
const toolSpanByCallId = new Map<string, ReturnType<typeof startToolSpan>>();
// Tracks pending tool_execution_start for which we haven't seen toolName yet
const pendingToolSpans = new Map<string, ReturnType<typeof startToolSpan>>();
/** Lazily initialize trace when entering autonomous 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<string, unknown>): 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<string, unknown>;
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<string, unknown>): 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<typeof setTimeout>
>();
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<void>((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 "autonomous 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<typeof setTimeout> | 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. autonomous 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<string, unknown>;
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 autonomous mode
if (!traceActive && isAutonomousCommand) {
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<string, unknown>;
const cumCost = data.cumulativeCost as
| Record<string, unknown>
| undefined;
if (cumCost) {
cumulativeCostUsd = Math.max(
cumulativeCostUsd,
Number(cumCost.costUsd ?? 0),
);
const tokens = data.tokens as Record<string, number> | 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<string, unknown>).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<string, unknown>;
const cumCost = data.cumulativeCost as
| Record<string, unknown>
| undefined;
if (cumCost) {
const tokens = data.tokens as Record<string, number> | 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<string, unknown>
| 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.chainAutonomous &&
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("Autonomous mode stopped..."/"Assisted 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 (isScheduledResumeNotification(eventObj)) {
providerAutoResumePending = true;
}
// Check for terminal notification before auto-responding
if (isBlockedNotification(eventObj) && !waitForProviderAutoResume) {
blocked = true;
}
// Detect "Milestone X ready." for autonomous 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("Autonomous mode resumed") ||
message.includes("Assisted 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
let signalShutdownInProgress = false;
const signalHandler = () => {
if (signalShutdownInProgress) return;
signalShutdownInProgress = true;
process.stderr.write(
"\n[headless] Interrupted, stopping child process...\n",
);
interrupted = true;
exitCode = EXIT_CANCELLED;
// 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.
void (async () => {
try {
await Promise.race([
client.stop(),
new Promise((resolve) => setTimeout(resolve, 2_500)),
]);
} catch (err: unknown) {
process.stderr.write(
`[headless] client.stop() failed: ${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 (initErr) {
const reason = initErr instanceof Error ? initErr.message : String(initErr);
process.stderr.write(
`[headless] v2 init failed (${reason}); refusing legacy v1 string-matching fallback.\n`,
);
await client.stop().catch(() => {});
if (timeoutTimer) clearTimeout(timeoutTimer);
if (idleTimer) clearTimeout(idleTimer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
process.exit(EXIT_ERROR);
}
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, signal) => {
if (!completed) {
const msg = `[headless] Child process exited unexpectedly with code ${code ?? "null"}${signal ? ` signal ${signal}` : ""}\n`;
process.stderr.write(msg);
exitCode = classifyUnexpectedChildExit(code, signal);
resolveCompletion();
}
});
}
if (!options.json) {
writeHeadlessLine(
`[headless] Running /${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}...`,
);
}
// Send the command — use bare /{subcommand} form so _tryExecuteExtensionCommand
// can look up the registered command directly without an "sf" namespace wrapper.
const command = `/${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 --autonomous and milestone creation succeeded,
// send the canonical autonomous command.
if (
isNewMilestone &&
options.chainAutonomous &&
milestoneReady &&
!blocked &&
exitCode === EXIT_SUCCESS
) {
if (!options.json) {
process.stderr.write(
"[headless] Milestone ready — chaining into autonomous mode...\n",
);
}
// Reset completion state for the autonomous mode phase.
// Disable the overall timeout — autonomous mode has its own internal supervisor.
if (timeoutTimer) clearTimeout(timeoutTimer);
completed = false;
milestoneReady = false;
blocked = false;
const autonomousCompletionPromise = new Promise<void>((resolve) => {
resolveCompletion = resolve;
});
try {
await waitForHeadlessExtensionCommands(client);
await client.prompt("/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 autonomousCompletionPromise;
}
}
// 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 =
(isAutonomousCommand || wasRequestedAutonomousCommand) && 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 };
}