/**
 * 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 { SessionManager } from "@singularity-forge/coding-agent";
import { RpcClient } from "@singularity-forge/rpc-client";
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.
			//
			// R091 gate: don't run triage on every watchdog cycle. The
			// triage-decider+review-code chain takes ~8 min per call which
			// gates milestone/slice work behind self-feedback drain. Skip
			// if the last triage drain ran less than SF_TRIAGE_INTERVAL_MS
			// (default 30 min) ago — the mid-loop dispatcher in auto/loop.js
			// still picks up entries filed during the cycle every 5 iters.
			// SF_TRIAGE_MAX (default 25) bounds the per-drain batch; the
			// prior value (5) leaked entries when open-queue depth exceeded
			// max per session, so the queue never fully drained even after
			// many cycles.
			const triageIntervalMs = Number(
				process.env.SF_TRIAGE_INTERVAL_MS || 30 * 60 * 1000,
			);
			const triageMaxBatch = Number(process.env.SF_TRIAGE_MAX || 25);
			const triageMarkerPath = join(
				process.cwd(),
				".sf",
				"runtime",
				"last-triage-at",
			);
			let shouldRunTriage = true;
			try {
				if (existsSync(triageMarkerPath)) {
					const last = Date.parse(
						readFileSync(triageMarkerPath, "utf8").trim(),
					);
					if (Number.isFinite(last) && Date.now() - last < triageIntervalMs) {
						shouldRunTriage = false;
						if (!options.json) {
							process.stderr.write(
								`[headless] autonomous: skipping triage drain (last ran ${Math.round(
									(Date.now() - last) / 60000,
								)}m ago, interval=${Math.round(
									triageIntervalMs / 60000,
								)}m; override via SF_TRIAGE_INTERVAL_MS)\n`,
							);
						}
					}
				}
			} catch {
				// Missing or unparsable marker — fall through to run triage.
			}

			if (shouldRunTriage) {
				try {
					const { handleTriage } = await import("./headless-triage.js");
					if (!options.json) {
						process.stderr.write(
							`[headless] autonomous: draining self-feedback triage queue first (max=${triageMaxBatch})...\n`,
						);
					}
					await handleTriage(process.cwd(), {
						apply: true,
						json: !!options.json,
						max: triageMaxBatch,
					});
					try {
						const runtimeDir = join(process.cwd(), ".sf", "runtime");
						if (!existsSync(runtimeDir)) {
							mkdirSync(runtimeDir, { recursive: true });
						}
						writeFileSync(triageMarkerPath, new Date().toISOString());
					} catch {
						// Marker write failure is non-fatal — triage still ran.
					}
				} 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 };
}
