Skip to content

feat: headless mode for non-TTY invocation#18

Open
elifarley wants to merge 16 commits into
aerovato:mainfrom
elifarley:feat/headless-mode
Open

feat: headless mode for non-TTY invocation#18
elifarley wants to merge 16 commits into
aerovato:mainfrom
elifarley:feat/headless-mode

Conversation

@elifarley

Copy link
Copy Markdown

Summary

Adds a headless mode so nitro "<request>" works correctly when invoked from a non-TTY parent (Claude Code's Bash tool, shell scripts, CI) without crashing the Ink TUI.

  • Auto-detection: When process.stdin.isTTY === false, headless mode activates automatically. --headless forces it; --tty overrides back to TUI.
  • Risk gating: Read Only/Normal commands auto-run. Dangerous/Extremely Dangerous are refused unless --yes is passed.
  • Multi-turn loop: The model can chain bash calls until it stops returning tool calls (max 20 turns, configurable via NITRO_MAX_TURNS).
  • Clean output: stdout gets $ <command> lines + cleaned tool output + Answer: <text>. stderr gets risk metadata, refusals, and errors.
  • Exit codes: 0 success, 1 error, 2 risk-gate refusal.

New files

File Purpose
src/headless/flags.ts Parses --headless, --tty, --yes from argv
src/headless/tty.ts Auto-detect non-TTY context
src/headless/transcript.ts Routes output to stdout/stderr, strips internal prefixes
src/headless/riskGate.ts Risk-level policy: auto-run vs refuse
src/headless/runHeadless.ts Multi-turn chat loop with preflight checks (EULA, provider)

Modified files

File Change
src/index.ts Pre-dispatcher strips flags, routes to headless on one-shot path
src/tools/bash.tsx Exported BASH_OUTPUT_STDOUT_PREFIX/BASH_OUTPUT_STDERR_PREFIX constants

Design decisions

  • Thin parallel path (Approach A): New src/headless/ module reuses logic/llm.ts and tools/bash.tsx directly, with zero Ink imports. The interactive TUI code is byte-for-byte untouched.
  • No EULA auto-accept: Headless aborts with exit 1 if EULA not yet accepted. The operator must run interactively once to accept.
  • No new dependencies: Ink stays confined to TUI imports.

Full design doc: docs/plans/2026-05-07-itro-headless-mode-design.md

Test plan

  • 160 tests passing (8 new headless test files under tests/headless/)
  • echo "" | nitro "echo hello" → exit 0, output printed to stdout
  • echo "" | nitro "rm -rf /tmp/foo" → exit 2, refusal on stderr
  • echo "" | nitro --yes "rm -rf /tmp/foo" → exit 0, auto-approved
  • nitro "list files" in real terminal → TUI unchanged
  • echo "" | nitro --tty "echo hello" → forces TUI, Ink crash confirms override works

🤖 Generated with Claude Code

elifarley and others added 16 commits May 7, 2026 08:50
Every entry path of itro routes through runChatScreen, which renders the full
Ink TUI. Ink's useInput requires raw-mode stdin, so when invoked from a non-TTY
parent (Claude Code's Bash tool, scripts, CI) the bash-approval prompt crashes
on render with "Raw mode is not supported on the current process.stdin".

This design documents a thin parallel headless code path that:

- Auto-detects !stdin.isTTY on the one-shot path; --tty / --headless overrides
- Reuses logic/llm.ts and tools/bash.tsx without importing any Ink module
- Risk-gates execution: Read Only / Normal auto-run; Dangerous+ refused unless
  --yes; exit code 2 signals "advise --yes" (1 reserved for general errors)
- Aborts with exit 1 if EULA not yet accepted (no auto-accept; only the existing
  Ink screen can mark eulaAgreed=true, run interactively once)
- Multi-turn agentic behavior preserved; TUI byte-for-byte unchanged

Approach A (parallel code path) chosen over B (extract shared core) to keep the
interactive surface untouched and make upstream merges painless. Duplication of
the chat loop is accepted, not staged for refactor.

Three local feature branches planned for incremental implementation; no GitHub
interaction yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These flags must be stripped before the existing subcommand dispatcher
in src/index.ts so that `nitro --headless "find files"` is treated the
same as `nitro "find files"` (request-with-spaces heuristic, line ~121).
Argument order is not constrained — flags can appear before, after, or
mixed with the request.

Branch 1 of 3 (feat/headless-flags). The helper is unused until the
dispatcher wires it in during branch 2 (feat/headless-runner).
V1 of detection: only the explicit --headless flag triggers headless mode.
The stdinIsTTY parameter is accepted but ignored — auto-detection lands
in branch 3 (feat/headless-autodetect) so branch 1 introduces zero
behavior change.

stdinIsTTY is taken as an argument (rather than read from process.stdin
directly) so the function is trivially testable and so the dispatcher
owns the "read once at the boundary" discipline.
Routes between stdout (transcript) and stderr (meta) and strips the
out:\t / err:\t prefixes that BashTool.executeBashCommand emits for the
model's disambiguation. Unprefixed lines route to stderr (amendment 8).

Streams are injected so tests don't spy on process.* and so future
output modes (quiet, json) can swap streams at the boundary without
helper changes.
Single source of truth for the risk-to-action policy:
  Read Only / Normal              -> run
  Dangerous / Extremely Dangerous -> run only if --yes, else refuse

Table-driven test locks the full 8-cell matrix; future policy changes
require updating both the helper and the matrix in lockstep.
Returns a discriminated Result rather than throwing or calling
process.exit so callers (and tests) own exit semantics. EULA is checked
first because a missing provider is the kind of error the user can
recover from in five seconds; an unaccepted EULA is a deliberate decline
that shouldn't be overshadowed.
Implements the async runHeadless() entry point that orchestrates the
full headless workflow: preflight -> LLM interaction -> tool execution
-> answer emission.

Key design decisions baked in:
- Amendment 7: Tool-name validation rejects non-Bash tools (headless
  only supports shell commands).
- Amendment 11: Drains fullStream on every turn to prevent memory leaks
  from unconsumed async generators.
- Amendment 12: Emits audit warnings to stderr when --yes auto-approves
  Dangerous or Extremely Dangerous commands.

Handles both AI SDK StaticToolCall.input (real runtime) and .args
(JSON string, test mocks) for correct parsing in all contexts.

Exit codes: 0 (success), 1 (error/preflight), 2 (risk refusal).
Two surgical changes in main():
1. parseFlags() runs first; --headless / --tty / --yes are stripped.
2. When --headless is set AND a one-shot request exists, runHeadless takes over.

The non-headless path is unchanged: same EULA check, same subcommand
switch (now reads from `remaining` rather than `args`). Includes
subcommand whitelist (Amendment 2), --headless-with-no-request guard
(Amendment 14), and printUsage update (Amendment 15).

After this commit, `nitro --headless "<req>"` works end-to-end.
Activates the headless path when stdin is not a TTY, with --tty as the
explicit "force TUI even without a TTY" escape hatch. Precedence:
  --tty               > everything (never headless)
  --headless          > stdinIsTTY (force headless even with TTY)
  neither             > headless iff !stdinIsTTY
These tests light up because branch 3's isHeadlessContext rewrite now
returns true for !stdinIsTTY. No production code change required --
the dispatcher already passed process.stdin.isTTY through.

Also fix the existing 'find files (no flag)' test which broke once
auto-detect activated: it was implicitly relying on process.stdin.isTTY
being true in vitest, but that assumption no longer holds.
Lock the contract from design section 5: stderr shows 'nitro: <msg>' always;
the stack trace is gated by DEBUG=1 so headless callers (Claude Code,
CI) get clean error messages by default but operators have an escape
hatch when debugging.

Also fix cli.test.ts which broke once auto-detect activated: the mock
for ../src/logic/settings was missing getSystemPrompt, and the "multi-word
request" test now routes through runHeadless when stdin is not a TTY.
Six amendments applied to the headless mode implementation:

- C1: Headless gets its own system prompt that replaces the single-turn
  directive with multi-turn guidance and strips AskUser tool references
  (no human present in headless mode).

- A6: Max-turn guard (default 20, NITRO_MAX_TURNS env override) prevents
  runaway loops when the model keeps requesting tool calls without
  converging to a final answer. Exits with code 1 and a diagnostic
  message pointing to the env var.

- A9: process.exitCode instead of process.exit() in the headless exit
  path so stdout/stderr buffers drain before the process terminates.
  process.exit() kills immediately and can truncate buffered writes.

- A10: Export BASH_OUTPUT_STDOUT_PREFIX / BASH_OUTPUT_STDERR_PREFIX
  constants from bash.tsx. transcript.ts imports them instead of
  maintaining duplicate string literals that could silently drift.

- A13: Deduplicate CaptureStream test helper into
  tests/headless/helpers.ts. Four test files (transcript, preflight,
  runHeadless, errorHandling) now import from the shared module.

- A17: Document stdin limitation in transcript.ts module header (stdin
  is only used for TTY detection; content is not passed to the model).

Also fixes pre-existing lint/TS errors:
- TS2322: BashToolOutput cast to JSONValue instead of Record<string,unknown>
- no-unsafe-assignment: annotate rawInput as `unknown`
- unbound-method: suppress false positive on vi.mocked(bashTool.execute)

Tests: 160 passed, 3 skipped (2 new test files: headlessPrompt.test.ts
for system prompt transformation, max-turn guard test in
runHeadless.test.ts).
@kevinMEH

kevinMEH commented May 8, 2026

Copy link
Copy Markdown
Member

Currently in the middle of a rewrite; will add a native "headless" mode so that harnesses (e.g.: OpenCode, Codex, CC) can run nitro "req" through their bash shell so nitro can act as a general purpose command line subagent. Stay tuned for updates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants