feat: headless mode for non-TTY invocation#18
Open
elifarley wants to merge 16 commits into
Open
Conversation
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).
Member
|
Currently in the middle of a rewrite; will add a native "headless" mode so that harnesses (e.g.: OpenCode, Codex, CC) can run |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.process.stdin.isTTY === false, headless mode activates automatically.--headlessforces it;--ttyoverrides back to TUI.Read Only/Normalcommands auto-run.Dangerous/Extremely Dangerousare refused unless--yesis passed.NITRO_MAX_TURNS).$ <command>lines + cleaned tool output +Answer: <text>. stderr gets risk metadata, refusals, and errors.0success,1error,2risk-gate refusal.New files
src/headless/flags.ts--headless,--tty,--yesfrom argvsrc/headless/tty.tssrc/headless/transcript.tssrc/headless/riskGate.tssrc/headless/runHeadless.tsModified files
src/index.tssrc/tools/bash.tsxBASH_OUTPUT_STDOUT_PREFIX/BASH_OUTPUT_STDERR_PREFIXconstantsDesign decisions
src/headless/module reuseslogic/llm.tsandtools/bash.tsxdirectly, with zero Ink imports. The interactive TUI code is byte-for-byte untouched.Full design doc:
docs/plans/2026-05-07-itro-headless-mode-design.mdTest plan
tests/headless/)echo "" | nitro "echo hello"→ exit 0, output printed to stdoutecho "" | nitro "rm -rf /tmp/foo"→ exit 2, refusal on stderrecho "" | nitro --yes "rm -rf /tmp/foo"→ exit 0, auto-approvednitro "list files"in real terminal → TUI unchangedecho "" | nitro --tty "echo hello"→ forces TUI, Ink crash confirms override works🤖 Generated with Claude Code