2012 lines
63 KiB
TypeScript
2012 lines
63 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,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import type { SessionInfo } from "@singularity-forge/pi-coding-agent";
|
|
import { RpcClient, SessionManager } from "@singularity-forge/pi-coding-agent";
|
|
import { error, formatStructuredError } from "./errors.js";
|
|
import {
|
|
AnswerInjector,
|
|
loadAndValidateAnswerFile,
|
|
} from "./headless-answers.js";
|
|
import {
|
|
bootstrapProject,
|
|
buildAutoBootstrapContext,
|
|
hasProjectMilestones,
|
|
loadContext,
|
|
} from "./headless-context.js";
|
|
|
|
import {
|
|
EXIT_BLOCKED,
|
|
EXIT_CANCELLED,
|
|
EXIT_ERROR,
|
|
EXIT_RELOAD,
|
|
EXIT_SUCCESS,
|
|
FIRE_AND_FORGET_METHODS,
|
|
IDLE_TIMEOUT_MS,
|
|
isAutoResumeScheduledNotification,
|
|
isBlockedNotification,
|
|
isInteractiveHeadlessTool,
|
|
isMilestoneReadyNotification,
|
|
isMilestoneReadyText,
|
|
isPauseNotification,
|
|
isQuickCommand,
|
|
isTerminalNotification,
|
|
MULTI_TURN_DEADLOCK_BACKSTOP_MS,
|
|
mapStatusToExitCode,
|
|
NEW_MILESTONE_IDLE_TIMEOUT_MS,
|
|
shouldArmHeadlessIdleTimeout,
|
|
shouldRestartHeadlessRun,
|
|
} from "./headless-events.js";
|
|
|
|
import type { HeadlessJsonResult, OutputFormat } from "./headless-types.js";
|
|
import { VALID_OUTPUT_FORMATS } from "./headless-types.js";
|
|
import type { ExtensionUIRequest, ProgressContext } from "./headless-ui.js";
|
|
import {
|
|
extractAssistantPreviewDelta,
|
|
formatHeadlessHeartbeat,
|
|
formatProgress,
|
|
formatPromptTraceLines,
|
|
formatTextEnd,
|
|
formatTextLine,
|
|
formatTextStart,
|
|
formatThinkingEnd,
|
|
formatThinkingLine,
|
|
formatThinkingStart,
|
|
handleExtensionUIRequest,
|
|
startSupervisedStdinReader,
|
|
summarizeToolArgs,
|
|
} from "./headless-ui.js";
|
|
import { getProjectSessionsDir } from "./project-sessions.js";
|
|
import {
|
|
ensureSfSymlink,
|
|
externalSfRoot,
|
|
hasExternalProjectState,
|
|
} from "./resources/extensions/sf/repo-identity.js";
|
|
import {
|
|
completeSpan,
|
|
flushTrace,
|
|
getActiveTrace,
|
|
initTraceCollector,
|
|
isTraceEnabled,
|
|
setTraceCost,
|
|
setTraceExitCode,
|
|
startToolSpan,
|
|
startUnitSpan,
|
|
traceError,
|
|
traceEvent,
|
|
} from "./resources/extensions/sf/trace-collector.js";
|
|
|
|
const HEADLESS_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
|
|
interface HeadlessTimeoutSolverEvalRecord {
|
|
runId: string;
|
|
reportPath: string;
|
|
dbRecorded: boolean;
|
|
}
|
|
|
|
async function runHeadlessTimeoutSolverEval(
|
|
basePath: string,
|
|
): Promise<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
|
|
auto?: boolean; // chain into autonomous mode after milestone creation
|
|
verbose?: boolean; // show tool calls in output
|
|
maxRestarts?: number; // auto-restart on crash (default 3, 0 to disable)
|
|
supervised?: boolean; // supervised mode: forward interactive requests to orchestrator
|
|
responseTimeout?: number; // timeout for orchestrator response (default 30000ms)
|
|
answers?: string; // path to answers JSON file
|
|
eventFilter?: Set<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
|
|
}
|
|
|
|
/**
|
|
* 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.startsWith("--")) {
|
|
if (arg === "--timeout" && i + 1 < args.length) {
|
|
options.timeout = parseInt(args[++i], 10);
|
|
if (Number.isNaN(options.timeout) || options.timeout < 0) {
|
|
process.stderr.write(
|
|
"[headless] Error: --timeout must be a non-negative integer (milliseconds, 0 to disable)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} else if (arg === "--json") {
|
|
options.json = true;
|
|
options.outputFormat = "stream-json";
|
|
} else if (arg === "--output-format" && i + 1 < args.length) {
|
|
const fmt = args[++i];
|
|
if (!VALID_OUTPUT_FORMATS.has(fmt)) {
|
|
process.stderr.write(
|
|
`[headless] Error: --output-format must be one of: text, json, stream-json (got '${fmt}')\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
options.outputFormat = fmt as OutputFormat;
|
|
if (fmt === "stream-json" || fmt === "json") {
|
|
options.json = true;
|
|
}
|
|
} else if (arg === "--model" && i + 1 < args.length) {
|
|
// --model can also be passed from the main CLI; headless-specific takes precedence
|
|
options.model = args[++i];
|
|
} else if (arg === "--context" && i + 1 < args.length) {
|
|
options.context = args[++i];
|
|
} else if (arg === "--context-text" && i + 1 < args.length) {
|
|
options.contextText = args[++i];
|
|
} else if (arg === "--auto") {
|
|
options.auto = true;
|
|
} else if (arg === "--verbose") {
|
|
options.verbose = true;
|
|
} else if (arg === "--max-restarts" && i + 1 < args.length) {
|
|
options.maxRestarts = parseInt(args[++i], 10);
|
|
if (Number.isNaN(options.maxRestarts) || options.maxRestarts < 0) {
|
|
process.stderr.write(
|
|
"[headless] Error: --max-restarts must be a non-negative integer\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} else if (arg === "--answers" && i + 1 < args.length) {
|
|
options.answers = args[++i];
|
|
} else if (arg === "--events" && i + 1 < args.length) {
|
|
options.eventFilter = new Set(args[++i].split(","));
|
|
options.json = true; // --events implies --json
|
|
if (options.outputFormat === "text") {
|
|
options.outputFormat = "stream-json";
|
|
}
|
|
} else if (arg === "--supervised") {
|
|
options.supervised = true;
|
|
options.json = true; // supervised implies json
|
|
if (options.outputFormat === "text") {
|
|
options.outputFormat = "stream-json";
|
|
}
|
|
} else if (arg === "--no-supervised") {
|
|
options.supervised = false;
|
|
} else if (arg === "--response-timeout" && i + 1 < args.length) {
|
|
options.responseTimeout = parseInt(args[++i], 10);
|
|
if (
|
|
Number.isNaN(options.responseTimeout) ||
|
|
options.responseTimeout <= 0
|
|
) {
|
|
process.stderr.write(
|
|
"[headless] Error: --response-timeout must be a positive integer (milliseconds)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} else if (arg === "--resume" && i + 1 < args.length) {
|
|
options.resumeSession = args[++i];
|
|
} else if (arg === "--bare") {
|
|
options.bare = true;
|
|
}
|
|
} else if (!commandSeen) {
|
|
if (arg === "autonomous") {
|
|
options.command = "autonomous";
|
|
options.auto = true; // autonomous subcommand implies --auto
|
|
} else {
|
|
options.command = arg;
|
|
}
|
|
options.commandExplicit = true;
|
|
commandSeen = true;
|
|
} else {
|
|
options.commandArgs.push(arg);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reload sentinel — written by kill_agent so runHeadless can resume the session.
|
|
// ---------------------------------------------------------------------------
|
|
const RELOAD_SENTINEL = join(process.env.TEMP ?? "/tmp", "sf-reload-sentinel");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Orchestrator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Run a headless session with automatic restart on crash and reload on
|
|
* agent-requested resume.
|
|
*
|
|
* Purpose: provide a resilient outer loop around a single headless session so
|
|
* transient RPC failures or agent-triggered restarts don't break CI pipelines
|
|
* or long-running autonomous workflows.
|
|
*
|
|
* Consumer: CLI entry point after parseHeadlessArgs.
|
|
*/
|
|
export async function runHeadless(options: HeadlessOptions): Promise<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 === "help") {
|
|
const { printSubcommandHelp } = await import("./help-text.js");
|
|
printSubcommandHelp("headless", process.env.SF_VERSION || "0.0.0");
|
|
return { exitCode: EXIT_SUCCESS, interrupted: false, timedOut: false };
|
|
}
|
|
if (options.command === "autonomous" && !options.resumeSession) {
|
|
bootstrapProject(process.cwd());
|
|
if (!(await hasProjectMilestones(process.cwd()))) {
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
"[headless] No milestones found; bootstrapping from repo docs and source inventory...\n",
|
|
);
|
|
}
|
|
options.command = "new-milestone";
|
|
options.auto = true;
|
|
options.contextText = buildAutoBootstrapContext(process.cwd());
|
|
}
|
|
}
|
|
const isNewMilestone = options.command === "new-milestone";
|
|
const isInit = options.command === "init";
|
|
|
|
// new-milestone involves codebase investigation + artifact writing — needs more time
|
|
if (isNewMilestone && options.timeout === 300_000) {
|
|
options.timeout = 600_000; // 10 minutes
|
|
}
|
|
|
|
// auto-mode sessions are long-running (minutes to hours) with their own internal
|
|
// per-unit timeout via auto-supervisor. Disable the overall timeout unless the
|
|
// user explicitly set --timeout.
|
|
const isAutoMode = options.command === "autonomous";
|
|
const wasRequestedAutoMode = requestedCommand === "autonomous";
|
|
// discuss and plan are multi-turn: they involve multiple question rounds,
|
|
// codebase scanning, and artifact writing before the workflow completes (#3547).
|
|
const isMultiTurnCommand =
|
|
options.command === "autonomous" ||
|
|
options.command === "next" ||
|
|
options.command === "discuss" ||
|
|
options.command === "plan";
|
|
|
|
// Headless uses the same SF command flow as TUI, but it is unattended by
|
|
// default: questions are answered by headless policy, not by waiting for a
|
|
// separate orchestrator. Opt into external question forwarding with
|
|
// --supervised when a caller really wants stdin/stdout mediation.
|
|
if (options.command === "autonomous" && options.supervised === undefined) {
|
|
options.supervised = false;
|
|
}
|
|
|
|
if (isAutoMode && options.timeout === 300_000) {
|
|
options.timeout = 0;
|
|
}
|
|
|
|
// Supervised mode cannot share stdin with --context -
|
|
if (options.supervised && options.context === "-") {
|
|
process.stderr.write(
|
|
"[headless] Error: --supervised cannot be used with --context - (both require stdin)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Load answer injection file
|
|
let injector: AnswerInjector | undefined;
|
|
if (options.answers) {
|
|
try {
|
|
const answerFile = loadAndValidateAnswerFile(resolve(options.answers));
|
|
injector = new AnswerInjector(answerFile);
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
`[headless] Loaded answer file: ${options.answers}\n`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
formatStructuredError(
|
|
error("Failed to load answer file", {
|
|
operation: "loadAndValidateAnswerFile",
|
|
file: resolve(options.answers ?? ""),
|
|
guidance:
|
|
"Validate the file is valid JSON matching the AnswerFile schema",
|
|
cause: err,
|
|
}),
|
|
"[headless]",
|
|
),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// For new-milestone, load context and bootstrap .sf/ before spawning RPC child
|
|
if (isNewMilestone) {
|
|
if (!options.context && !options.contextText) {
|
|
process.stderr.write(
|
|
"[headless] Error: new-milestone requires --context <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);
|
|
}
|
|
|
|
// Bootstrap .sf/ if needed
|
|
const sfDir = join(process.cwd(), ".sf");
|
|
if (!existsSync(sfDir)) {
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
"[headless] Bootstrapping .sf/ project structure...\n",
|
|
);
|
|
}
|
|
bootstrapProject(process.cwd());
|
|
}
|
|
|
|
// Write context to temp file for the RPC child to read
|
|
const runtimeDir = join(sfDir, "runtime");
|
|
mkdirSync(runtimeDir, { recursive: true });
|
|
writeFileSync(
|
|
join(runtimeDir, "headless-context.md"),
|
|
contextContent,
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
if (isInit) {
|
|
if (!options.json) {
|
|
process.stderr.write("[headless] Initializing SF project state...\n");
|
|
}
|
|
bootstrapProject(process.cwd());
|
|
const initializedSfDir = join(process.cwd(), ".sf");
|
|
if (options.json) {
|
|
process.stdout.write(
|
|
JSON.stringify(
|
|
{
|
|
status: "initialized",
|
|
sfDir: initializedSfDir,
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
} else {
|
|
process.stdout.write(`[headless] Initialized ${initializedSfDir}\n`);
|
|
}
|
|
return { exitCode: EXIT_SUCCESS, interrupted: false, timedOut: false };
|
|
}
|
|
|
|
// Validate .sf/ directory (skip for new-milestone since we just bootstrapped it)
|
|
const sfDir = join(process.cwd(), ".sf");
|
|
const legacyDir = join(process.cwd(), "." + ["g", "sd"].join(""));
|
|
if (!isNewMilestone && !existsSync(sfDir)) {
|
|
if (existsSync(legacyDir)) {
|
|
renameSync(legacyDir, sfDir);
|
|
process.stderr.write(
|
|
"[headless] Migrated legacy project state to .sf/\n",
|
|
);
|
|
} else if (repairMissingSfSymlinkForHeadless(process.cwd())) {
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
"[headless] Re-linked .sf to existing external project state\n",
|
|
);
|
|
}
|
|
} else if (options.command === "autonomous" && options.commandExplicit) {
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
"[headless] No .sf/ project state found; initializing for autonomous mode...\n",
|
|
);
|
|
}
|
|
bootstrapProject(process.cwd());
|
|
} else {
|
|
process.stderr.write(
|
|
formatStructuredError(
|
|
error("No .sf/ directory found", {
|
|
operation: "validateProjectState",
|
|
file: process.cwd(),
|
|
guidance:
|
|
"'sf headless init' (non-interactive) or 'sf init' (interactive)",
|
|
}),
|
|
"[headless]",
|
|
),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Query: read-only state snapshot, no RPC child needed
|
|
if (options.command === "query") {
|
|
const { handleQuery } = await import("./headless-query.js");
|
|
const result = await handleQuery(process.cwd());
|
|
return { exitCode: result.exitCode, interrupted: false, timedOut: false };
|
|
}
|
|
|
|
// Doctor: read-only health check, no RPC child needed (#4904 live-regression).
|
|
// The interactive `/sf doctor` command lives in the SF extension; this CLI
|
|
// path lets non-interactive callers (CI, recovery scripts, the live-regression
|
|
// suite) get the same diagnostic without a TTY.
|
|
if (options.command === "doctor") {
|
|
const wantsJson = options.json || options.commandArgs.includes("--json");
|
|
const wantsFix = options.commandArgs.includes("--fix");
|
|
const { runSFDoctor, formatDoctorReport, formatDoctorReportJson } =
|
|
await import("./resources/extensions/sf/doctor.js");
|
|
let exitCode = 1;
|
|
try {
|
|
const report = await runSFDoctor(process.cwd(), { fix: wantsFix });
|
|
const out = wantsJson
|
|
? formatDoctorReportJson(report)
|
|
: formatDoctorReport(report);
|
|
process.stdout.write(`${out}\n`);
|
|
exitCode = report.ok ? 0 : 1;
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
process.stderr.write(`[headless] doctor failed: ${msg}\n`);
|
|
exitCode = 1;
|
|
}
|
|
// Bypass the auto-restart loop in runHeadless — doctor is a one-shot
|
|
// diagnostic; exit 1 means "issues detected", not "crashed".
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
// Resolve CLI path for the child process
|
|
const cliPath = process.env.SF_BIN_PATH || process.argv[1];
|
|
if (!cliPath) {
|
|
process.stderr.write(
|
|
"[headless] Error: Cannot determine CLI path. Set SF_BIN_PATH or run via sf.\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Create RPC client
|
|
const clientOptions: Record<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",
|
|
};
|
|
// Propagate --bare to the child process
|
|
if (options.bare) {
|
|
clientOptions.args = [
|
|
...((clientOptions.args as string[]) || []),
|
|
"--bare",
|
|
];
|
|
}
|
|
|
|
const client = new RpcClient(clientOptions);
|
|
|
|
// Event tracking
|
|
let totalEvents = 0;
|
|
let toolCallCount = 0;
|
|
let blocked = false;
|
|
let completed = false;
|
|
let exitCode = 0;
|
|
let milestoneReady = false; // tracks "Milestone X ready." for auto-chaining
|
|
let timedOut = false; // true only when the overall timeout timer fires
|
|
// Rolling buffer for milestone-ready detection across split streaming deltas.
|
|
// Capped at 200 chars — long enough to bridge any realistic delta boundary.
|
|
let milestoneDetectionBuffer = "";
|
|
let providerAutoResumePending = false;
|
|
const recentEvents: TrackedEvent[] = [];
|
|
const interactiveToolCallIds = new Set<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 auto-mode and new-milestone+auto.
|
|
// Uses maybeStartTrace() — not called upfront so we pay zero cost when disabled.
|
|
|
|
const cwd = process.cwd();
|
|
let traceActive = false; // true once maybeStartTrace succeeded
|
|
// Current unit span — tool spans are children of this
|
|
let activeUnitSpan: ReturnType<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 auto-mode. Idempotent. */
|
|
function maybeStartTrace(sessionId?: string): void {
|
|
if (traceActive) return;
|
|
if (!isTraceEnabled()) return;
|
|
const trace = initTraceCollector(
|
|
cwd,
|
|
sessionId ?? null,
|
|
options.command ?? "run",
|
|
options.model ?? null,
|
|
);
|
|
if (trace) traceActive = true;
|
|
}
|
|
|
|
/** Flush the active trace to disk. Idempotent. */
|
|
function finalizeAndFlushTrace(): void {
|
|
if (!traceActive) return;
|
|
try {
|
|
setTraceExitCode(exitCode);
|
|
setTraceCost(
|
|
cumulativeInputTokens,
|
|
cumulativeOutputTokens,
|
|
cumulativeCacheReadTokens,
|
|
cumulativeCacheWriteTokens,
|
|
cumulativeCostUsd,
|
|
);
|
|
flushTrace(cwd);
|
|
} catch {
|
|
// Swallow trace flush errors — don't disrupt the main exit path
|
|
}
|
|
traceActive = false;
|
|
activeUnitSpan = null;
|
|
toolSpanByCallId.clear();
|
|
pendingToolSpans.clear();
|
|
}
|
|
|
|
/**
|
|
* Parse a unit notify message and start a unit span.
|
|
* Matches: "[unit] milestone M001 starting" or "[unit] slice M001/S01 starting" etc.
|
|
*/
|
|
function handleUnitStart(message: string): void {
|
|
const parsed = parseHeadlessUnitNotification(message);
|
|
if (!parsed || parsed.kind !== "start") return;
|
|
activeUnitSpan = startUnitSpan(
|
|
parsed.unitType as "milestone" | "slice" | "task",
|
|
parsed.unitId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse a unit end notify message and close the active unit span.
|
|
* Matches: "[unit] milestone M001 ended -> ok" etc.
|
|
*/
|
|
function handleUnitEnd(message: string): void {
|
|
const parsed = parseHeadlessUnitNotification(message);
|
|
if (!parsed || parsed.kind !== "end") return;
|
|
const unitId = parsed.unitId;
|
|
const verdict = parsed.verdict ?? "error";
|
|
|
|
// Find the unit span by ID (may not be the top-of-stack if nested)
|
|
if (!activeUnitSpan) return;
|
|
if (activeUnitSpan.attributes.unitId !== unitId) return;
|
|
|
|
const status =
|
|
verdict === "ok"
|
|
? "ok"
|
|
: verdict === "failed"
|
|
? "error"
|
|
: verdict === "cancelled"
|
|
? "cancelled"
|
|
: verdict === "timeout"
|
|
? "timeout"
|
|
: "error";
|
|
|
|
if (status !== "ok") {
|
|
traceEvent(activeUnitSpan, `unit-${status}`, { unitId, verdict });
|
|
traceError(
|
|
activeUnitSpan,
|
|
`Unit ${unitId} ended with verdict: ${verdict}`,
|
|
);
|
|
}
|
|
completeSpan(activeUnitSpan, status);
|
|
activeUnitSpan = null;
|
|
}
|
|
|
|
/**
|
|
* Handle tool_execution_start: create a tool span under the active unit (or root if no unit active).
|
|
*/
|
|
function handleToolStart(toolName: string, toolCallId: string): void {
|
|
if (!traceActive) return;
|
|
const parentSpan = activeUnitSpan ?? getActiveTrace()?.rootSpan;
|
|
if (!parentSpan) return;
|
|
const toolSpan = startToolSpan(parentSpan, toolName, toolCallId);
|
|
toolSpanByCallId.set(toolCallId, toolSpan);
|
|
}
|
|
|
|
/**
|
|
* Handle tool_execution_end: close the matching tool span.
|
|
*/
|
|
function handleToolEnd(toolCallId: string, isError: boolean): void {
|
|
const span = toolSpanByCallId.get(toolCallId);
|
|
if (!span) return;
|
|
toolSpanByCallId.delete(toolCallId);
|
|
if (isError) {
|
|
traceError(span, "Tool execution failed");
|
|
}
|
|
const durationMs =
|
|
span.endTime && span.startTime
|
|
? span.endTime - span.startTime
|
|
: undefined;
|
|
if (durationMs !== undefined) {
|
|
span.attributes.toolDurationMs = durationMs;
|
|
}
|
|
completeSpan(span, isError ? "error" : "ok");
|
|
}
|
|
|
|
// Emit HeadlessJsonResult to stdout for --output-format json batch mode
|
|
function emitBatchJsonResult(): void {
|
|
if (options.outputFormat !== "json") return;
|
|
const duration = Date.now() - startTime;
|
|
const status: HeadlessJsonResult["status"] = blocked
|
|
? "blocked"
|
|
: exitCode === EXIT_CANCELLED
|
|
? "cancelled"
|
|
: exitCode === EXIT_ERROR
|
|
? timedOut
|
|
? "timeout"
|
|
: "error"
|
|
: "success";
|
|
const result: HeadlessJsonResult = {
|
|
schemaVersion: 1,
|
|
status,
|
|
exitCode,
|
|
sessionId: lastSessionId,
|
|
duration,
|
|
cost: {
|
|
total: cumulativeCostUsd,
|
|
input_tokens: cumulativeInputTokens,
|
|
output_tokens: cumulativeOutputTokens,
|
|
cache_read_tokens: cumulativeCacheReadTokens,
|
|
cache_write_tokens: cumulativeCacheWriteTokens,
|
|
},
|
|
toolCalls: toolCallCount,
|
|
events: totalEvents,
|
|
};
|
|
process.stdout.write(JSON.stringify(result) + "\n");
|
|
}
|
|
|
|
function trackEvent(event: Record<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 "auto-mode stopped" terminal notifications,
|
|
// and child-process exit catches crashes. The idle timer here is a
|
|
// deadlock BACKSTOP only: 30 minutes, long enough to never misfire on
|
|
// legitimate LLM reasoning, short enough to recover from a real hang.
|
|
// (MULTI_TURN_DEADLOCK_BACKSTOP_MS)
|
|
let idleTimer: ReturnType<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. auto-mode)
|
|
const timeoutTimer =
|
|
options.timeout > 0
|
|
? setTimeout(() => {
|
|
process.stderr.write(
|
|
`[headless] Timeout after ${options.timeout / 1000}s\n`,
|
|
);
|
|
timedOut = true;
|
|
exitCode = EXIT_ERROR;
|
|
resolveCompletion();
|
|
}, options.timeout)
|
|
: null;
|
|
|
|
const heartbeatTimer =
|
|
!options.json && options.outputFormat === "text"
|
|
? setInterval(() => {
|
|
if (completed) return;
|
|
const now = Date.now();
|
|
const quietMs = now - lastVisibleProgressAt;
|
|
if (quietMs < HEADLESS_HEARTBEAT_INTERVAL_MS) return;
|
|
const lastEvent = recentEvents[recentEvents.length - 1];
|
|
process.stderr.write(
|
|
formatHeadlessHeartbeat({
|
|
elapsedMs: now - startTime,
|
|
quietMs,
|
|
totalEvents,
|
|
toolCallCount,
|
|
eventDelta: totalEvents - lastHeartbeatEventCount,
|
|
toolCallDelta: toolCallCount - lastHeartbeatToolCallCount,
|
|
openToolCount: toolStartTimes.size,
|
|
openToolDetails: summarizeOpenHeadlessTools(now),
|
|
activeUnit: activeHeadlessUnit,
|
|
activeModel: activeHeadlessModel,
|
|
lastEventType: lastEvent?.type,
|
|
lastEventDetail: lastEvent?.detail,
|
|
}) + "\n",
|
|
);
|
|
lastHeartbeatEventCount = totalEvents;
|
|
lastHeartbeatToolCallCount = toolCallCount;
|
|
}, HEADLESS_HEARTBEAT_INTERVAL_MS)
|
|
: null;
|
|
|
|
// Event handler
|
|
client.onEvent((event) => {
|
|
const eventObj = event as unknown as Record<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 auto-mode
|
|
if (!traceActive && isAutoMode) {
|
|
maybeStartTrace(lastSessionId);
|
|
}
|
|
// Start a tool span if tracing is active
|
|
if (traceActive && toolCallId && toolName) {
|
|
handleToolStart(toolName, toolCallId);
|
|
}
|
|
} else if (eventType === "tool_execution_update") {
|
|
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? "");
|
|
if (toolCallId) {
|
|
const existing = openToolInfoByCallId.get(toolCallId);
|
|
openToolInfoByCallId.set(toolCallId, {
|
|
toolName: String(eventObj.toolName ?? existing?.toolName ?? ""),
|
|
args: eventObj.args ?? existing?.args,
|
|
startedAt: existing?.startedAt ?? Date.now(),
|
|
});
|
|
}
|
|
} else if (eventType === "tool_execution_end") {
|
|
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? "");
|
|
if (toolCallId) {
|
|
if (interactiveToolCallIds.has(toolCallId)) {
|
|
lastInteractiveToolEndTime = Date.now();
|
|
}
|
|
interactiveToolCallIds.delete(toolCallId);
|
|
openToolInfoByCallId.delete(toolCallId);
|
|
}
|
|
// Close the tool span if tracing is active
|
|
if (traceActive && toolCallId) {
|
|
const isError = eventObj.isError === true || eventObj.error != null;
|
|
handleToolEnd(toolCallId, isError);
|
|
}
|
|
}
|
|
|
|
resetIdleTimer();
|
|
|
|
// Answer injector: observe events for question metadata
|
|
injector?.observeEvent(eventObj);
|
|
|
|
// --json / --output-format stream-json: forward events as JSONL to stdout (filtered if --events)
|
|
// --output-format json (batch mode): suppress streaming, track cost for final result
|
|
if (options.json && options.outputFormat === "stream-json") {
|
|
if (!options.eventFilter || options.eventFilter.has(eventType)) {
|
|
process.stdout.write(JSON.stringify(eventObj) + "\n");
|
|
}
|
|
} else if (options.outputFormat === "json") {
|
|
// Batch mode: silently track cost_update events (cumulative-max per K004)
|
|
const eventType = String(eventObj.type ?? "");
|
|
if (eventType === "cost_update") {
|
|
const data = eventObj as Record<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.auto && ame?.type === "text_delta") {
|
|
const deltaText = String(ame?.delta ?? ame?.text ?? "");
|
|
if (deltaText) {
|
|
milestoneDetectionBuffer = (
|
|
milestoneDetectionBuffer + deltaText
|
|
).slice(-200);
|
|
milestoneReady ||= isMilestoneReadyText(milestoneDetectionBuffer);
|
|
}
|
|
}
|
|
if (ame && options.verbose) {
|
|
const ameType = String(ame.type ?? "");
|
|
|
|
// --- Text streaming ---
|
|
if (ameType === "text_start") {
|
|
inTextBlock = true;
|
|
process.stderr.write(formatTextStart());
|
|
lastVisibleProgressAt = Date.now();
|
|
} else if (ameType === "text_delta") {
|
|
const delta = String(ame.delta ?? ame.text ?? "");
|
|
if (delta) {
|
|
if (!inTextBlock) {
|
|
// Edge case: delta without start
|
|
inTextBlock = true;
|
|
process.stderr.write(formatTextStart());
|
|
}
|
|
process.stderr.write(delta);
|
|
lastVisibleProgressAt = Date.now();
|
|
}
|
|
} else if (ameType === "text_end") {
|
|
if (inTextBlock) {
|
|
process.stderr.write(formatTextEnd() + "\n");
|
|
lastVisibleProgressAt = Date.now();
|
|
inTextBlock = false;
|
|
}
|
|
}
|
|
|
|
// --- Thinking streaming ---
|
|
else if (ameType === "thinking_start") {
|
|
inThinkingBlock = true;
|
|
process.stderr.write(formatThinkingStart());
|
|
lastVisibleProgressAt = Date.now();
|
|
} else if (ameType === "thinking_delta") {
|
|
const delta = String(ame.delta ?? ame.text ?? "");
|
|
if (delta) {
|
|
if (!inThinkingBlock) {
|
|
inThinkingBlock = true;
|
|
process.stderr.write(formatThinkingStart());
|
|
}
|
|
process.stderr.write(delta);
|
|
lastVisibleProgressAt = Date.now();
|
|
}
|
|
} else if (ameType === "thinking_end") {
|
|
if (inThinkingBlock) {
|
|
process.stderr.write(formatThinkingEnd() + "\n");
|
|
lastVisibleProgressAt = Date.now();
|
|
inThinkingBlock = false;
|
|
}
|
|
}
|
|
}
|
|
// Non-verbose: accumulate separated thinking/text previews for
|
|
// truncated one-liners before tool calls and message end.
|
|
else {
|
|
const previewDelta = extractAssistantPreviewDelta(ame);
|
|
if (previewDelta?.kind === "text") {
|
|
assistantTextBuffer += previewDelta.text;
|
|
} else if (previewDelta?.kind === "thinking") {
|
|
thinkingBuffer += previewDelta.text;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track tool execution start timestamps
|
|
if (eventType === "tool_execution_start") {
|
|
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? "");
|
|
if (toolCallId) toolStartTimes.set(toolCallId, Date.now());
|
|
}
|
|
|
|
// Close any open streaming blocks before tool calls or message end
|
|
if (
|
|
options.verbose &&
|
|
(eventType === "tool_execution_start" || eventType === "message_end")
|
|
) {
|
|
if (inTextBlock) {
|
|
process.stderr.write("\n");
|
|
inTextBlock = false;
|
|
}
|
|
if (inThinkingBlock) {
|
|
process.stderr.write("\n");
|
|
inThinkingBlock = false;
|
|
}
|
|
}
|
|
// Non-verbose: flush accumulated buffers as truncated one-liners
|
|
else if (
|
|
!options.verbose &&
|
|
(eventType === "tool_execution_start" || eventType === "message_end")
|
|
) {
|
|
if (assistantTextBuffer.trim()) {
|
|
writeHeadlessLine(formatTextLine(assistantTextBuffer));
|
|
assistantTextBuffer = "";
|
|
}
|
|
if (thinkingBuffer.trim()) {
|
|
writeHeadlessLine(formatThinkingLine(thinkingBuffer));
|
|
thinkingBuffer = "";
|
|
}
|
|
}
|
|
|
|
// Compute tool duration for tool_execution_end
|
|
let toolDuration: number | undefined;
|
|
let isToolError = false;
|
|
if (eventType === "tool_execution_end") {
|
|
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? "");
|
|
const startTime = toolStartTimes.get(toolCallId);
|
|
if (startTime) {
|
|
toolDuration = Date.now() - startTime;
|
|
toolStartTimes.delete(toolCallId);
|
|
}
|
|
isToolError = eventObj.isError === true || eventObj.error != null;
|
|
}
|
|
|
|
const ctx: ProgressContext = {
|
|
verbose: !!options.verbose,
|
|
toolDuration,
|
|
isError: isToolError,
|
|
lastCost: eventType === "agent_end" ? lastCostData : undefined,
|
|
};
|
|
|
|
const line = formatProgress(eventObj, ctx);
|
|
if (line && line !== lastProgressLine) {
|
|
writeHeadlessLine(line);
|
|
lastProgressLine = line;
|
|
}
|
|
}
|
|
|
|
if (eventType === "extension_error" && !completed) {
|
|
exitCode = EXIT_ERROR;
|
|
completed = true;
|
|
resolveCompletion();
|
|
return;
|
|
}
|
|
|
|
// Handle execution_complete (v2 structured completion)
|
|
// Skip for multi-turn commands (auto, next) — their completion is detected via
|
|
// isTerminalNotification("Auto-mode stopped..."/"Step-mode stopped..."), not per-turn events
|
|
if (
|
|
eventObj.type === "execution_complete" &&
|
|
!completed &&
|
|
!isMultiTurnCommand
|
|
) {
|
|
completed = true;
|
|
const status = String(eventObj.status ?? "success");
|
|
exitCode = mapStatusToExitCode(status);
|
|
if (eventObj.status === "blocked") blocked = true;
|
|
resolveCompletion();
|
|
return;
|
|
}
|
|
|
|
// Handle extension_ui_request
|
|
if (eventObj.type === "extension_ui_request" && clientStarted) {
|
|
const waitForProviderAutoResume =
|
|
providerAutoResumePending && isPauseNotification(eventObj);
|
|
|
|
if (isAutoResumeScheduledNotification(eventObj)) {
|
|
providerAutoResumePending = true;
|
|
}
|
|
|
|
// Check for terminal notification before auto-responding
|
|
if (isBlockedNotification(eventObj) && !waitForProviderAutoResume) {
|
|
blocked = true;
|
|
}
|
|
|
|
// Detect "Milestone X ready." for auto-mode chaining
|
|
if (isMilestoneReadyNotification(eventObj)) {
|
|
milestoneReady = true;
|
|
}
|
|
|
|
if (isTerminalNotification(eventObj) && !waitForProviderAutoResume) {
|
|
completed = true;
|
|
}
|
|
|
|
// Structured trace: handle unit start/end notify messages
|
|
if (eventObj.method === "notify") {
|
|
const message = String(eventObj.message ?? "");
|
|
observeHeadlessNotification(message);
|
|
if (
|
|
message.includes("Auto-mode resumed") ||
|
|
message.includes("Step-mode resumed") ||
|
|
(message.includes("[unit]") && message.includes("starting"))
|
|
) {
|
|
providerAutoResumePending = false;
|
|
}
|
|
if (traceActive) {
|
|
if (message.includes("[unit]") && message.includes("starting")) {
|
|
handleUnitStart(message);
|
|
} else if (message.includes("[unit]") && message.includes("ended")) {
|
|
handleUnitEnd(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Answer injection: try to handle with pre-supplied answers before supervised/auto
|
|
if (
|
|
injector &&
|
|
!FIRE_AND_FORGET_METHODS.has(String(eventObj.method ?? ""))
|
|
) {
|
|
if (injector.tryHandle(eventObj, injectorStdinAdapter)) {
|
|
if (completed) {
|
|
exitCode = blocked ? EXIT_BLOCKED : EXIT_SUCCESS;
|
|
resolveCompletion();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const method = String(eventObj.method ?? "");
|
|
const shouldSupervise =
|
|
options.supervised &&
|
|
!supervisedFallback &&
|
|
!FIRE_AND_FORGET_METHODS.has(method);
|
|
|
|
if (shouldSupervise) {
|
|
// Interactive request in supervised mode — let orchestrator respond
|
|
const eventId = String(eventObj.id ?? "");
|
|
const timer = setTimeout(() => {
|
|
pendingResponseTimers.delete(eventId);
|
|
handleExtensionUIRequest(
|
|
eventObj as unknown as ExtensionUIRequest,
|
|
client,
|
|
);
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
type: "supervised_timeout",
|
|
id: eventId,
|
|
method,
|
|
}) + "\n",
|
|
);
|
|
}, responseTimeout);
|
|
pendingResponseTimers.set(eventId, timer);
|
|
} else {
|
|
handleExtensionUIRequest(
|
|
eventObj as unknown as ExtensionUIRequest,
|
|
client,
|
|
);
|
|
}
|
|
|
|
// If we detected a terminal notification, resolve after responding
|
|
if (completed) {
|
|
exitCode = blocked ? EXIT_BLOCKED : EXIT_SUCCESS;
|
|
resolveCompletion();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Quick commands: resolve on first agent_end
|
|
if (
|
|
eventObj.type === "agent_end" &&
|
|
isQuickCommand(options.command) &&
|
|
!completed
|
|
) {
|
|
completed = true;
|
|
resolveCompletion();
|
|
return;
|
|
}
|
|
|
|
// Long-running commands: agent_end after tool execution — possible completion
|
|
// The idle timer + terminal notification handle this case.
|
|
});
|
|
|
|
// Signal handling
|
|
const signalHandler = () => {
|
|
process.stderr.write(
|
|
"\n[headless] Interrupted, stopping child process...\n",
|
|
);
|
|
interrupted = true;
|
|
exitCode = EXIT_CANCELLED;
|
|
// Kill child process — don't await, just fire and exit.
|
|
// The main flow may be awaiting a promise that resolves when the child dies,
|
|
// which would race with this handler. Exit synchronously to ensure correct exit code.
|
|
// Log stop failures to stderr for head/headless parity — interactive-mode logs its
|
|
// stop errors too. Exit code is already forced via process.exit, so logging is
|
|
// purely observability and doesn't change shutdown semantics.
|
|
try {
|
|
client.stop().catch((err: unknown) => {
|
|
process.stderr.write(
|
|
`[headless] client.stop() rejected: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
});
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[headless] client.stop() threw: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
// Emit batch JSON result if in json mode before exiting
|
|
if (options.outputFormat === "json") {
|
|
emitBatchJsonResult();
|
|
}
|
|
finalizeAndFlushTrace();
|
|
process.exit(exitCode);
|
|
};
|
|
process.on("SIGINT", signalHandler);
|
|
process.on("SIGTERM", signalHandler);
|
|
|
|
// Start the RPC session
|
|
try {
|
|
await client.start();
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
formatStructuredError(
|
|
error("Failed to start RPC session", {
|
|
operation: "RpcClient.start",
|
|
file: cliPath,
|
|
guidance: "Verify SF_BIN_PATH is set or reinstall singularity-forge",
|
|
cause: err,
|
|
}),
|
|
"[headless]",
|
|
),
|
|
);
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
process.exit(1);
|
|
}
|
|
|
|
// v2 protocol negotiation — attempt init for structured completion events
|
|
let _v2Enabled = false;
|
|
try {
|
|
await client.init({ clientId: "sf-headless" });
|
|
_v2Enabled = true;
|
|
} catch {
|
|
process.stderr.write(
|
|
"[headless] Warning: v2 init failed, falling back to v1 string-matching\n",
|
|
);
|
|
}
|
|
|
|
clientStarted = true;
|
|
|
|
// --resume: resolve session ID and switch to it
|
|
if (options.resumeSession) {
|
|
const projectSessionsDir = getProjectSessionsDir(process.cwd());
|
|
const sessions = await SessionManager.list(
|
|
process.cwd(),
|
|
projectSessionsDir,
|
|
);
|
|
const result = resolveResumeSession(sessions, options.resumeSession);
|
|
if (result.error) {
|
|
process.stderr.write(
|
|
formatStructuredError(
|
|
error(result.error, {
|
|
operation: "resolveResumeSession",
|
|
guidance:
|
|
"Use the full session ID, or run 'sf sessions' to list and select interactively",
|
|
}),
|
|
"[headless]",
|
|
),
|
|
);
|
|
await client.stop();
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
process.exit(1);
|
|
}
|
|
const matched = result.session!;
|
|
const switchResult = await client.switchSession(matched.path);
|
|
if (switchResult.cancelled) {
|
|
process.stderr.write(
|
|
formatStructuredError(
|
|
error(`Session switch to '${matched.id}' was cancelled`, {
|
|
operation: "switchSession",
|
|
file: matched.path,
|
|
guidance:
|
|
"Check extension logs or disable the cancelling extension",
|
|
}),
|
|
"[headless]",
|
|
),
|
|
);
|
|
await client.stop();
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
process.exit(1);
|
|
}
|
|
process.stderr.write(`[headless] Resuming session ${matched.id}\n`);
|
|
}
|
|
|
|
// Build injector adapter — wraps client.sendUIResponse for AnswerInjector's writeToStdin interface
|
|
injectorStdinAdapter = (data: string) => {
|
|
try {
|
|
const parsed = JSON.parse(data.trim());
|
|
if (parsed.type === "extension_ui_response" && parsed.id) {
|
|
const { id, value, values, confirmed, cancelled } = parsed;
|
|
client.sendUIResponse(id, { value, values, confirmed, cancelled });
|
|
}
|
|
} catch {
|
|
process.stderr.write(
|
|
"[headless] Warning: injector adapter received unparseable data\n",
|
|
);
|
|
}
|
|
};
|
|
|
|
// Start supervised stdin reader for orchestrator commands
|
|
if (options.supervised) {
|
|
stopSupervisedReader = startSupervisedStdinReader(client, (id) => {
|
|
const timer = pendingResponseTimers.get(id);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
pendingResponseTimers.delete(id);
|
|
}
|
|
});
|
|
// Ensure stdin is in flowing mode for JSONL reading
|
|
process.stdin.resume();
|
|
}
|
|
|
|
// Detect child process crash (read-only exit event subscription — not stdin access)
|
|
const internalProcess = (client as any).process as ChildProcess;
|
|
if (internalProcess) {
|
|
internalProcess.on("exit", (code) => {
|
|
if (!completed) {
|
|
const msg = `[headless] Child process exited unexpectedly with code ${code ?? "null"}\n`;
|
|
process.stderr.write(msg);
|
|
exitCode = EXIT_ERROR;
|
|
resolveCompletion();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!options.json) {
|
|
writeHeadlessLine(
|
|
`[headless] Running /sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}...`,
|
|
);
|
|
}
|
|
|
|
// Send the command
|
|
const command = `/sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}`;
|
|
try {
|
|
await waitForHeadlessExtensionCommands(client);
|
|
await client.prompt(command);
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[headless] Error: Failed to send prompt: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
exitCode = EXIT_ERROR;
|
|
}
|
|
|
|
// Wait for completion
|
|
if (exitCode === EXIT_SUCCESS || exitCode === EXIT_BLOCKED) {
|
|
await completionPromise;
|
|
}
|
|
|
|
// Autonomous-mode chaining: if --auto and milestone creation succeeded,
|
|
// send the canonical autonomous command.
|
|
if (
|
|
isNewMilestone &&
|
|
options.auto &&
|
|
milestoneReady &&
|
|
!blocked &&
|
|
exitCode === EXIT_SUCCESS
|
|
) {
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
"[headless] Milestone ready — chaining into autonomous mode...\n",
|
|
);
|
|
}
|
|
|
|
// Reset completion state for the auto-mode phase.
|
|
// Disable the overall timeout — auto-mode has its own internal supervisor.
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
completed = false;
|
|
milestoneReady = false;
|
|
blocked = false;
|
|
const autoCompletionPromise = new Promise<void>((resolve) => {
|
|
resolveCompletion = resolve;
|
|
});
|
|
|
|
try {
|
|
await waitForHeadlessExtensionCommands(client);
|
|
await client.prompt("/sf autonomous");
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[headless] Error: Failed to start autonomous mode: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
exitCode = EXIT_ERROR;
|
|
}
|
|
|
|
if (exitCode === EXIT_SUCCESS || exitCode === EXIT_BLOCKED) {
|
|
await autoCompletionPromise;
|
|
}
|
|
}
|
|
|
|
// Cleanup
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
for (const timer of pendingResponseTimers.values()) clearTimeout(timer);
|
|
pendingResponseTimers.clear();
|
|
stopSupervisedReader?.();
|
|
process.stdin.removeListener("close", onStdinClose);
|
|
process.removeListener("SIGINT", signalHandler);
|
|
process.removeListener("SIGTERM", signalHandler);
|
|
|
|
// Flush any active trace before stopping the client
|
|
finalizeAndFlushTrace();
|
|
|
|
await client.stop();
|
|
|
|
const solverEvalRecord =
|
|
(isAutoMode || wasRequestedAutoMode) && timedOut
|
|
? await runHeadlessTimeoutSolverEval(process.cwd())
|
|
: null;
|
|
|
|
// Summary
|
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
const status = blocked
|
|
? "blocked"
|
|
: exitCode === EXIT_CANCELLED
|
|
? "cancelled"
|
|
: exitCode === EXIT_ERROR
|
|
? timedOut
|
|
? "timeout"
|
|
: "error"
|
|
: "complete";
|
|
const durationMs = Date.now() - startTime;
|
|
|
|
await recordHeadlessRunBestEffort(process.cwd(), {
|
|
runId: headlessRunId,
|
|
command: `/sf ${requestedCommand}${requestedCommandArgs.length > 0 ? " " + requestedCommandArgs.join(" ") : ""}`,
|
|
status,
|
|
exitCode,
|
|
timedOut,
|
|
interrupted,
|
|
restartCount,
|
|
maxRestarts: options.maxRestarts ?? 3,
|
|
durationMs,
|
|
totalEvents,
|
|
toolCalls: toolCallCount,
|
|
solverEvalRunId: solverEvalRecord?.runId ?? null,
|
|
solverEvalReportPath: solverEvalRecord?.reportPath ?? null,
|
|
details: {
|
|
effectiveCommand: `/sf ${options.command}${options.commandArgs.length > 0 ? " " + options.commandArgs.join(" ") : ""}`,
|
|
outputFormat: options.outputFormat,
|
|
eventFilter: options.eventFilter ? [...options.eventFilter] : [],
|
|
solverEvalDbRecorded: solverEvalRecord?.dbRecorded ?? null,
|
|
},
|
|
});
|
|
|
|
process.stderr.write(`[headless] Status: ${status}\n`);
|
|
process.stderr.write(`[headless] Duration: ${duration}s\n`);
|
|
process.stderr.write(
|
|
`[headless] Events: ${totalEvents} total, ${toolCallCount} tool calls\n`,
|
|
);
|
|
if (options.eventFilter) {
|
|
process.stderr.write(
|
|
`[headless] Event filter: ${[...options.eventFilter].join(", ")}\n`,
|
|
);
|
|
}
|
|
if (restartCount > 0) {
|
|
process.stderr.write(`[headless] Restarts: ${restartCount}\n`);
|
|
}
|
|
|
|
// Answer injection stats
|
|
if (injector) {
|
|
const stats = injector.getStats();
|
|
process.stderr.write(
|
|
`[headless] Answers: ${stats.questionsAnswered} answered, ${stats.questionsDefaulted} defaulted, ${stats.secretsProvided} secrets\n`,
|
|
);
|
|
for (const warning of injector.getUnusedWarnings()) {
|
|
process.stderr.write(`${warning}\n`);
|
|
}
|
|
}
|
|
|
|
// On failure, print last 5 events for diagnostics
|
|
if (exitCode !== 0) {
|
|
const lastFive = recentEvents.slice(-5);
|
|
if (lastFive.length > 0) {
|
|
process.stderr.write("[headless] Last events:\n");
|
|
for (const e of lastFive) {
|
|
process.stderr.write(` ${e.type}${e.detail ? `: ${e.detail}` : ""}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit structured JSON result in batch mode
|
|
emitBatchJsonResult();
|
|
|
|
return { exitCode, interrupted, timedOut };
|
|
}
|