runHeadless method

Future<int> runHeadless(
  1. String prompt, {
  2. List<ImageContent> images = const [],
  3. HepWriter? hep,
  4. bool waitForJobs = false,
  5. 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,
  };
}