fix: separate headless transport from autonomous mode

This commit is contained in:
Mikael Hugo 2026-05-06 02:24:15 +02:00
parent 4f3020da21
commit a1fd6cfc05
7 changed files with 36 additions and 43 deletions

View file

@ -189,9 +189,12 @@ Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed a
`sf headless` runs `/sf` commands without a TUI — designed for CI, cron jobs, and scripted automation. It spawns a child process in RPC mode, auto-responds to interactive prompts, detects completion, and exits with meaningful exit codes.
```bash
# Run autonomous mode (default)
# Show headless command help
sf headless
# Run autonomous mode without the TUI
sf headless autonomous
# Run a single unit
sf headless next

View file

@ -119,7 +119,7 @@ function printNonTtyErrorAndExit(
);
if (includeWebHint) {
process.stderr.write(
"[sf] sf headless Autonomous mode without TUI\n",
"[sf] sf headless autonomous Autonomous mode without TUI\n",
);
}
process.exit(1);
@ -561,7 +561,7 @@ if (cliFlags.messages[0] === "sessions") {
cliFlags._selectedSessionPath = selected.path;
}
// `sf headless` — run autonomous mode without TUI
// `sf headless ...` — run explicit /sf commands without the TUI
if (cliFlags.messages[0] === "headless") {
await ensureRtkBootstrap();
// Sync bundled resources before headless runs (#3471). Without this,

View file

@ -294,7 +294,7 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
timeout: 300_000,
json: false,
outputFormat: "text",
command: "autonomous",
command: "help",
commandExplicit: false,
commandArgs: [],
};
@ -488,6 +488,11 @@ async function runHeadlessOnce(
): Promise<{ exitCode: number; interrupted: boolean }> {
let interrupted = false;
const startTime = Date.now();
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 };
}
if (options.command === "autonomous" && !options.resumeSession) {
bootstrapProject(process.cwd());
if (!hasMilestones(process.cwd())) {
@ -521,29 +526,12 @@ async function runHeadlessOnce(
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
// 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) {
// 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
const autoSupervisor = prefs?.preferences
? (prefs.preferences as Record<string, unknown>)["auto_supervisor"]
: undefined;
options.supervised =
autoSupervisor !== undefined
? (((autoSupervisor as Record<string, unknown>)?.[
"supervised_mode"
] as boolean | undefined) ?? true)
: true;
} catch {
options.supervised = true;
}
options.supervised = false;
}
if (isAutoMode && options.timeout === 300_000) {

View file

@ -171,7 +171,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
headless: [
"Usage: sf headless [flags] [command] [args...]",
"",
"Run /sf commands without the TUI. Default command: autonomous",
"Run /sf commands without the TUI. Pass an explicit command such as autonomous, next, status, or query.",
"",
"Flags:",
" --timeout N Overall timeout in ms (default: 300000)",
@ -186,7 +186,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" --events <types> Filter JSONL output to specific event types (comma-separated)",
"",
"Commands:",
" autonomous Run all queued product units continuously (default)",
" autonomous Run all queued product units continuously",
" next Run one unit",
" status Show progress dashboard",
" new-milestone Create a milestone from a specification document",
@ -204,7 +204,8 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" stream-json Stream JSONL events to stdout in real time (same as --json)",
"",
"Examples:",
" sf headless Run /sf autonomous",
" sf headless Show this help",
" sf headless autonomous Run /sf autonomous without the TUI",
" sf headless next Run one unit",
" sf headless --output-format json autonomous Structured JSON result on stdout",
" sf headless --json status Machine-readable JSONL stream",
@ -288,7 +289,7 @@ export function printHelp(version: string): void {
" autonomous [args] Run autonomous mode without TUI (pipeable)\n",
);
process.stdout.write(
" headless [cmd] [args] Run /sf commands without TUI (default: autonomous)\n",
" headless [cmd] [args] Run explicit /sf commands without TUI\n",
);
process.stdout.write(
" graph <subcommand> Manage knowledge graph (build, query, status, diff)\n",

View file

@ -82,9 +82,10 @@ if (
process.exit(0);
}
// Fast-path invalid headless invocations before importing cli.ts. These paths
// are commonly used by smoke tests and orchestrators; they should return a
// clear diagnostic without paying extension/resource startup cost.
// Fast-path invalid headless flags before importing cli.ts. Project-state
// validation belongs to headless.ts because bare `sf headless` is help, `init`
// is allowed before .sf exists, and explicit commands decide their own state
// requirements.
if (firstArg === "headless") {
for (let i = 1; i < args.length; i += 1) {
const arg = args[i];
@ -98,13 +99,6 @@ if (firstArg === "headless") {
}
}
}
if (args.length === 1 && !existsSync(join(process.cwd(), ".sf"))) {
process.stderr.write(
"[headless] Error: No .sf/ directory found. Run 'sf headless init' or pass an explicit headless subcommand.\n",
);
process.exit(1);
}
}
// Schedule due-items banner — lightweight check before heavy imports

View file

@ -49,7 +49,7 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions {
timeout: 300_000,
json: false,
outputFormat: "text",
command: "autonomous",
command: "help",
commandArgs: [],
};
@ -126,6 +126,13 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions {
// ─── --output-format flag parsing ──────────────────────────────────────────
test("bare headless defaults to help, not autonomous", () => {
const opts = parseHeadlessArgs(["node", "sf", "headless"]);
assert.equal(opts.command, "help");
assert.equal(opts.auto, undefined);
assert.deepEqual(opts.commandArgs, []);
});
test("--output-format text sets outputFormat to text", () => {
const opts = parseHeadlessArgs([
"node",

View file

@ -34,7 +34,7 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions {
const options: HeadlessOptions = {
timeout: 300_000,
json: false,
command: "auto",
command: "help",
commandArgs: [],
};
@ -75,7 +75,7 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions {
}
} else if (!positionalStarted) {
positionalStarted = true;
options.command = arg === "autonomous" ? "auto" : arg;
options.command = arg;
} else {
options.commandArgs.push(arg);
}