runHeadless method
Future<int>
runHeadless(
- String prompt, {
- List<
ImageContent> images = const [], - HepWriter? hep,
- bool waitForJobs = false,
- StreamJsonWriter? streamJson,
Runs a single non-interactive prompt (headless mode: fah "<prompt>")
and returns the process exit code: 0 on success, 1 when the run ends
with a provider error, 130 when aborted (Ctrl-C via CliIO.interrupts).
Tool errors the agent recovers from still exit 0 — the exit code
reflects the run's terminal state, like claude/pi.
Unlike run there is no banner, no input prompt, no slash-command handling, and no steering; the session persists exactly like a REPL turn (including auto-compaction). The host's CliIO should be non-interactive and route CliIO.writeln diagnostics to stderr so CliIO.write (the assistant text) is the only stdout content.
Implementation
Future<int> runHeadless(
String prompt, {
List<ImageContent> images = const [],
HepWriter? hep,
bool waitForJobs = false,
StreamJsonWriter? streamJson,
}) async {
_hep = hep;
// Cube cache restore, mirroring [run]'s boot (the headless run sees the
// same cached trees a REPL session would).
await _cubeBootRestore();
_session = await _initializeSession();
// Ownership lease (#428, E7): a headless run NEVER spawns a second
// writer over a live lease — it refuses with the banner (exit 3) so
// wake loops reopen interactively instead of fighting the owner.
// Restart honesty (issue #450): detached jobs from the previous run.
await _waiting.captureLostJobs();
final leaseBlocked = await _claimSessionLeaseHeadless();
if (leaseBlocked != null) {
io.writeln(viewerBannerText(leaseBlocked, stale: false));
return 3;
}
// HEP (issue #155) + stream-json (issue #695) headers: the FIRST
// stdout line of each structured mode, written the moment the
// session id exists — before any event can race them.
await _writeHeadlessEventHeaders(hep: hep, streamJson: streamJson);
// Issue #332: rehydrate/settle the subagent registry exactly like the
// interactive [run] boot. A headless run (a wake run, a restart) used
// to start from an EMPTY registry, so zombie 'running' rows from the
// previous process were never settled here AND the headless run's
// first spawn persisted a snapshot that REPLACED the old rows. Awaited
// before the prompt: any spawn the run triggers must see the loaded
// registry instead of racing it.
await _subagentManager.rehydrate();
// Session scope (tools.yaml next to the session file) is live now.
unawaited(AgentCliTools(this).rebuildToolAvailability());
// Sleep prevention (#325/#326) — headless wraps exactly ONE run, so
// both holds bracket it the same way: session-held acquires on the
// session open, per-run on the run start (the prompt below).
await acquirePowerAssertions();
runPowerAssertionsStarted();
// Warm the endpoint metadata (model list, dial features, reported
// limits) BEFORE the first turn; failures are silent.
await _warmModelCacheQuietly();
// The same pre-flight compaction guard as the REPL's [_runPrompt]:
// a resumed session already over the threshold must compact BEFORE
// the first request, or it goes out over-window and gets rejected.
await _maybeAutoCompact();
final interruptSub = io.interrupts.listen((_) {
if (isBusy) _agent.abort();
});
final taskSub = _taskConfig.jobManager.completions.listen(
_onTaskJobCompleted,
);
final hepSub = hep == null ? null : _agent.subscribe(hep.handleEvent);
// Stream-json subscription (issue #695): like the HEP writer, the
// stream writer sees every agent event; its encoder drops the
// fa-native ones. Unsubscribed in the finally below so a failed run
// never leaks the listener into the next one.
final streamJsonSub = streamJson == null
? null
: _agent.subscribe(streamJson.handleEvent);
// Terminal-outcome capture (issue #413): the visible transcript is
// REBUILT by post-run compaction (checkpoint records replace the
// assistant turns entirely), so the exit code cannot be derived from
// `state.messages` — the last completed turn's stop reason is taken
// from the turn events as they fire, before any folding.
StopReason? terminalStopReason;
final turnSub = _agent.subscribe((event, _) {
if (event is TurnEndEvent) {
terminalStopReason = event.message.stopReason;
}
});
_headlessMode = true;
try {
if (images.isEmpty) {
await _agent.prompt(_redactUserText(prompt));
} else {
// --attach (issue #155): the files ride the first user message as
// image content blocks next to the (redacted) prompt text.
await _agent.promptMessage(
UserMessage(
content: [
TextContent(text: _redactUserText(prompt)),
...images,
],
timestamp: DateTime.now(),
),
);
}
// Settle the finished turn exactly like the REPL's [_runPrompt]
// (issue #413): the over-window guard's one-shot compaction +
// continuation used to be REPL-only, so a headless run that
// exhausted the window mid-task abandoned it and exited — the
// freed window was never used.
final lastMessage = _agent.state.messages.lastOrNull;
final finished = await _settleAfterPrompt(
lastMessage,
isAutoContinue: false,
);
// Awaits any in-flight TTSR retry chain, persists the messages, and
// auto-compacts — the same end-of-turn sequence as a REPL run. The
// continuation paths recurse through [_runPrompt], which finalizes
// with its own [_afterRun]; only a normally-finished turn does.
if (finished) await _afterRun();
await _awaitHeadlessBackgroundJobs();
// Visible waiting (issue #450): stay for the waiters when opted in,
// otherwise print the honest detach summary before exiting.
await _waiting.waitForJobsOrSummarize(waitForJobs: waitForJobs);
} catch (error) {
io.writeln(
_keyStatusView.errorLine('$error', _agent.state.model.baseUrl),
);
return 1;
} finally {
_headlessMode = false;
_autoFoldCount = 0;
turnSub();
await releasePowerAssertions();
await _cubeCacheSaveQuietly();
await interruptSub.cancel();
await taskSub.cancel();
hepSub?.call();
streamJsonSub?.call();
}
// The exit code describes the LAST completed turn's terminal outcome
// (captured from the turn events above) — not the visible transcript,
// which post-run compaction rebuilds: the checkpoint fold drops the
// assistant turns entirely, and the classic trim marker lands after
// the error stop; both used to mask a failed run as exit 0 (issue
// #413).
return switch (terminalStopReason) {
StopReason.error => 1,
StopReason.aborted => 130,
_ => 0,
};
}