1455 lines
45 KiB
TypeScript
1455 lines
45 KiB
TypeScript
/**
|
|
* Headless Orchestrator — `sf headless`
|
|
*
|
|
* Runs 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.
|
|
*
|
|
* Exit codes:
|
|
* 0 — complete (command finished successfully)
|
|
* 1 — error or timeout
|
|
* 10 — blocked (command reported a blocker)
|
|
* 11 — cancelled (SIGINT/SIGTERM received)
|
|
*/
|
|
|
|
import type { ChildProcess } from "node:child_process";
|
|
import { existsSync, mkdirSync, renameSync, 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 {
|
|
AnswerInjector,
|
|
loadAndValidateAnswerFile,
|
|
} from "./headless-answers.js";
|
|
import { bootstrapProject, loadContext } from "./headless-context.js";
|
|
|
|
import {
|
|
EXIT_BLOCKED,
|
|
EXIT_CANCELLED,
|
|
EXIT_ERROR,
|
|
EXIT_SUCCESS,
|
|
FIRE_AND_FORGET_METHODS,
|
|
IDLE_TIMEOUT_MS,
|
|
MULTI_TURN_DEADLOCK_BACKSTOP_MS,
|
|
isBlockedNotification,
|
|
isInteractiveHeadlessTool,
|
|
isMilestoneReadyNotification,
|
|
isQuickCommand,
|
|
isTerminalNotification,
|
|
mapStatusToExitCode,
|
|
NEW_MILESTONE_IDLE_TIMEOUT_MS,
|
|
shouldArmHeadlessIdleTimeout,
|
|
} 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 {
|
|
formatHeadlessHeartbeat,
|
|
formatProgress,
|
|
formatTextEnd,
|
|
formatTextStart,
|
|
formatThinkingEnd,
|
|
formatThinkingLine,
|
|
formatThinkingStart,
|
|
handleExtensionUIRequest,
|
|
startSupervisedStdinReader,
|
|
} from "./headless-ui.js";
|
|
import { getProjectSessionsDir } from "./project-sessions.js";
|
|
import {
|
|
ensureGsdSymlink,
|
|
externalGsdRoot,
|
|
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;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface HeadlessOptions {
|
|
timeout: number;
|
|
json: boolean;
|
|
outputFormat: OutputFormat;
|
|
model?: string;
|
|
command: string;
|
|
commandArgs: string[];
|
|
context?: string; // file path or '-' for stdin
|
|
contextText?: string; // inline text
|
|
auto?: boolean; // chain into auto-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
|
|
}
|
|
|
|
export function repairMissingSfSymlinkForHeadless(
|
|
basePath: string,
|
|
): string | null {
|
|
const sfDir = join(basePath, ".sf");
|
|
if (existsSync(sfDir)) return sfDir;
|
|
|
|
const externalPath = externalGsdRoot(basePath);
|
|
if (!hasExternalProjectState(externalPath)) return null;
|
|
|
|
const linkedPath = ensureGsdSymlink(basePath);
|
|
return existsSync(sfDir) ? linkedPath : null;
|
|
}
|
|
|
|
interface TrackedEvent {
|
|
type: string;
|
|
timestamp: number;
|
|
detail?: string;
|
|
}
|
|
|
|
interface HeadlessUnitNotification {
|
|
kind: "start" | "end";
|
|
unitType: string;
|
|
unitId: string;
|
|
verdict?: string;
|
|
}
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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.
|
|
*/
|
|
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}`,
|
|
};
|
|
}
|
|
return { session: matches[0] };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI Argument Parser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
|
|
const options: HeadlessOptions = {
|
|
timeout: 300_000,
|
|
json: false,
|
|
outputFormat: "text",
|
|
command: "auto",
|
|
commandArgs: [],
|
|
};
|
|
|
|
const args = argv.slice(2);
|
|
|
|
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 (options.command === "auto") {
|
|
options.command = arg;
|
|
} else {
|
|
options.commandArgs.push(arg);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Orchestrator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 or blocked — exit normally
|
|
if (result.exitCode === EXIT_SUCCESS || result.exitCode === EXIT_BLOCKED) {
|
|
process.exit(result.exitCode);
|
|
}
|
|
|
|
// Crash/error — check if we should restart
|
|
if (restartCount >= maxRestarts) {
|
|
process.stderr.write(
|
|
`[headless] Max restarts (${maxRestarts}) reached. Exiting.\n`,
|
|
);
|
|
process.exit(result.exitCode);
|
|
}
|
|
|
|
// Don't restart if SIGINT/SIGTERM was received
|
|
if (result.interrupted) {
|
|
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 }> {
|
|
let interrupted = false;
|
|
const startTime = Date.now();
|
|
const isNewMilestone = options.command === "new-milestone";
|
|
|
|
// 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 === "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 === "auto" ||
|
|
options.command === "next" ||
|
|
options.command === "discuss" ||
|
|
options.command === "plan";
|
|
|
|
// Auto-mode defaults to supervised: wait for user input instead of exiting on questions
|
|
// This is the desired behavior - auto should wait, not exit on blocked
|
|
// Can be disabled via --no-supervised or preferences.auto_supervisor.supervised_mode: false
|
|
if (options.command === "auto" && options.supervised === undefined) {
|
|
// Check preferences for default
|
|
try {
|
|
const { loadEffectiveSFPreferences } = await import(
|
|
"./resources/extensions/sf/preferences.js"
|
|
);
|
|
const prefs = loadEffectiveSFPreferences();
|
|
// Default to true unless explicitly set to false in preferences
|
|
options.supervised =
|
|
prefs?.preferences?.auto_supervisor?.supervised_mode ?? true;
|
|
} catch {
|
|
options.supervised = true;
|
|
}
|
|
}
|
|
|
|
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(
|
|
`[headless] Error loading answer file: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
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(
|
|
`[headless] Error loading context: ${err instanceof Error ? err.message : String(err)}\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",
|
|
);
|
|
}
|
|
|
|
// Validate .sf/ directory (skip for new-milestone since we just bootstrapped it)
|
|
const sfDir = join(process.cwd(), ".sf");
|
|
const legacyDir = join(process.cwd(), ".gsd");
|
|
if (!isNewMilestone && !existsSync(sfDir)) {
|
|
if (existsSync(legacyDir)) {
|
|
renameSync(legacyDir, sfDir);
|
|
process.stderr.write(
|
|
"[headless] Migrated .gsd/ → .sf/ (legacy GSD2 project detected)\n",
|
|
);
|
|
} else if (repairMissingSfSymlinkForHeadless(process.cwd())) {
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
"[headless] Re-linked .sf to existing external project state\n",
|
|
);
|
|
}
|
|
} else {
|
|
process.stderr.write(
|
|
"[headless] Error: No .sf/ directory found in current directory.\n",
|
|
);
|
|
process.stderr.write(
|
|
"[headless] Run 'sf' interactively first to initialize a project.\n",
|
|
);
|
|
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 };
|
|
}
|
|
|
|
// 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 { runSFDoctor, formatDoctorReport, formatDoctorReportJson } =
|
|
await import("./resources/extensions/sf/doctor.js");
|
|
let exitCode = 1;
|
|
try {
|
|
const report = await runSFDoctor(process.cwd());
|
|
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
|
|
const recentEvents: TrackedEvent[] = [];
|
|
let lastVisibleProgressAt = Date.now();
|
|
const interactiveToolCallIds = new Set<string>();
|
|
|
|
// 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>();
|
|
let lastCostData:
|
|
| { costUsd: number; inputTokens: number; outputTokens: number }
|
|
| undefined;
|
|
let thinkingBuffer = "";
|
|
// 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,
|
|
options.command,
|
|
options.model,
|
|
);
|
|
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
|
|
? totalEvents === 0
|
|
? "error"
|
|
: "timeout"
|
|
: "success";
|
|
const result: HeadlessJsonResult = {
|
|
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 ?? ""}`
|
|
: 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();
|
|
}
|
|
|
|
|
|
// 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;
|
|
|
|
function resetIdleTimer(): void {
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
if (
|
|
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`,
|
|
);
|
|
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];
|
|
writeHeadlessLine(
|
|
formatHeadlessHeartbeat({
|
|
elapsedMs: now - startTime,
|
|
quietMs,
|
|
totalEvents,
|
|
toolCallCount,
|
|
lastEventType: lastEvent?.type,
|
|
lastEventDetail: lastEvent?.detail,
|
|
}),
|
|
);
|
|
}, HEADLESS_HEARTBEAT_INTERVAL_MS)
|
|
: null;
|
|
|
|
|
|
// Event handler
|
|
client.onEvent((event) => {
|
|
const eventObj = event as unknown as Record<string, unknown>;
|
|
trackEvent(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 && 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_end") {
|
|
const toolCallId = String(eventObj.toolCallId ?? eventObj.id ?? "");
|
|
if (toolCallId) {
|
|
interactiveToolCallIds.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 ?? "",
|
|
);
|
|
}
|
|
} 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,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Stream assistant text and thinking deltas in verbose mode
|
|
if (eventType === "message_update") {
|
|
const ame = eventObj.assistantMessageEvent as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (ame && options.verbose) {
|
|
const ameType = String(ame.type ?? "");
|
|
|
|
// --- Text streaming ---
|
|
if (ameType === "text_start") {
|
|
inTextBlock = true;
|
|
lastVisibleProgressAt = Date.now();
|
|
process.stderr.write(formatTextStart());
|
|
} else if (ameType === "text_delta") {
|
|
const delta = String(ame.delta ?? ame.text ?? "");
|
|
if (delta) {
|
|
if (!inTextBlock) {
|
|
// Edge case: delta without start
|
|
inTextBlock = true;
|
|
process.stderr.write(formatTextStart());
|
|
}
|
|
lastVisibleProgressAt = Date.now();
|
|
process.stderr.write(delta);
|
|
}
|
|
} else if (ameType === "text_end") {
|
|
if (inTextBlock) {
|
|
lastVisibleProgressAt = Date.now();
|
|
process.stderr.write(formatTextEnd() + "\n");
|
|
inTextBlock = false;
|
|
}
|
|
}
|
|
|
|
// --- Thinking streaming ---
|
|
else if (ameType === "thinking_start") {
|
|
inThinkingBlock = true;
|
|
lastVisibleProgressAt = Date.now();
|
|
process.stderr.write(formatThinkingStart());
|
|
} else if (ameType === "thinking_delta") {
|
|
const delta = String(ame.delta ?? ame.text ?? "");
|
|
if (delta) {
|
|
if (!inThinkingBlock) {
|
|
inThinkingBlock = true;
|
|
process.stderr.write(formatThinkingStart());
|
|
}
|
|
lastVisibleProgressAt = Date.now();
|
|
process.stderr.write(delta);
|
|
}
|
|
} else if (ameType === "thinking_end") {
|
|
if (inThinkingBlock) {
|
|
lastVisibleProgressAt = Date.now();
|
|
process.stderr.write(formatThinkingEnd() + "\n");
|
|
inThinkingBlock = false;
|
|
}
|
|
}
|
|
}
|
|
// Non-verbose: accumulate text_delta for truncated one-liner
|
|
else if (ame?.type === "text_delta") {
|
|
thinkingBuffer += String(ame.delta ?? ame.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 buffer as truncated one-liner
|
|
else if (
|
|
!options.verbose &&
|
|
thinkingBuffer.trim() &&
|
|
(eventType === "tool_execution_start" || eventType === "message_end")
|
|
) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// Check for terminal notification before auto-responding
|
|
if (isBlockedNotification(eventObj)) {
|
|
blocked = true;
|
|
}
|
|
|
|
// Detect "Milestone X ready." for auto-mode chaining
|
|
if (isMilestoneReadyNotification(eventObj)) {
|
|
milestoneReady = true;
|
|
}
|
|
|
|
if (isTerminalNotification(eventObj)) {
|
|
completed = true;
|
|
}
|
|
|
|
// Structured trace: handle unit start/end notify messages
|
|
if (eventObj.method === "notify") {
|
|
const message = String(eventObj.message ?? "");
|
|
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 (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
// 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(
|
|
`[headless] Error: Failed to start RPC session: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
process.exit(1);
|
|
}
|
|
|
|
// 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(`[headless] Error: ${result.error}\n`);
|
|
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(
|
|
`[headless] Error: Session switch to '${matched.id}' was cancelled by an extension\n`,
|
|
);
|
|
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 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;
|
|
}
|
|
|
|
// Auto-mode chaining: if --auto and milestone creation succeeded, send /sf auto
|
|
if (
|
|
isNewMilestone &&
|
|
options.auto &&
|
|
milestoneReady &&
|
|
!blocked &&
|
|
exitCode === EXIT_SUCCESS
|
|
) {
|
|
if (!options.json) {
|
|
process.stderr.write(
|
|
"[headless] Milestone ready — chaining into auto-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 client.prompt("/sf auto");
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[headless] Error: Failed to start auto-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 (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
if (idleTimer) clearTimeout(idleTimer);
|
|
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();
|
|
|
|
// Summary
|
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
const status = blocked
|
|
? "blocked"
|
|
: exitCode === EXIT_CANCELLED
|
|
? "cancelled"
|
|
: exitCode === EXIT_ERROR
|
|
? totalEvents === 0
|
|
? "error"
|
|
: "timeout"
|
|
: "complete";
|
|
|
|
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 };
|
|
}
|