autopilot

Architecture

This document is for contributors and future-self maintenance. End-user docs live in the README. For local-dev setup see CONTRIBUTING.md.

What ships

The project ships a single artifact: the autopilot CLI. It drives autonomous Copilot CLI loops by spawning each iteration as a fresh copilot -p ... subprocess, parses the JSONL stdout for terminal markers, and tails a per-run events.jsonl event stream so a separate terminal can autopilot watch the live timeline.

The previous in-session Copilot CLI extension (ralph_loop / self_improve / grow_project / ralph_status / ralph_pause / ralph_resume / ralph_stop tools) was retired — see CHANGELOG.md. All forward work lives in packages/tui/.

Source layout

packages/tui/
├── bin/tui.mjs               # CLI entry — argv parser + dispatcher
├── src/
│   ├── components/             # Ink layout components for `run` + `watch`
│   │   ├── App.mjs             # Top-level Ink tree wiring panes together
│   │   ├── Controls.mjs        # Footer controls / keybinding hints
│   │   ├── Header.mjs          # Top bar (run label, version, run-state badge)
│   │   ├── LastCommit.mjs      # Replay-on-mount excerpt of the latest commit
│   │   ├── LiveOutputPane.mjs  # Tail of the agent's live session output
│   │   ├── StagesRow.mjs       # SDLC stage strip
│   │   ├── SubstagesPane.mjs   # Per-stage substage progress
│   │   ├── TasksPane.mjs       # Active iter task / tool-call view
│   │   └── Timeline.mjs        # Live event-stream excerpt
│   ├── events-emit.mjs       # Zero-dep JSONL emitter (write side)
│   ├── events.mjs            # Pure event contract (read side)
│   ├── plain.mjs             # Plain-mode log line formatter
│   ├── prompts.mjs           # Baked SDLC prompts (PROMPT_SELF_IMPROVE, PROMPT_GROW_PROJECT)
│   ├── run-ui.mjs            # Ink renderer for `run`'s live UI
│   ├── runner.mjs            # `autopilot run` driver — spawns each iter, tracks state.json
│   ├── stream-format.mjs     # Live-output formatter for the Copilot session log
│   ├── tail.mjs              # Live tail iterator for `watch`
│   ├── version.mjs           # Reads packages/tui/package.json for `--version` + Header
│   ├── watch.mjs             # Watch dispatcher (TTY → Ink, non-TTY → plain)
│   └── writer.mjs            # JSONL reader + index aggregator (`list`/`stats`/`prune`)
├── test/                     # node:test suite
└── package.json              # Ink/React/Yoga deps for the renderer
scripts/
├── check.mjs                 # Portable equivalent of CI's syntax-check job
└── ralph-tui-fresh.sh        # Optional `git pull --ff-only` wrapper for long runs

autopilot run — the driver

runner.mjs is the heart of the project. Each iteration is a fresh subprocess:

copilot -p "<baked or user prompt>" --allow-all-tools --output-format json [--resume <sessionId>]

The driver parses JSONL stdout for two markers:

Context modes

State machine — state.json

Each run owns a directory at <runs-root>/<runId>/ containing events.jsonl, optional index.jsonl (for the runs-root index), and state.json. The state file holds:

State writes are CAS-protected via a per-state-file lockfile (acquireLock / releaseLock in runner.mjs) so concurrent --pause + --stop from two terminals do not lose updates.

JSONL event contract

The runner emits one JSON object per line via events-emit.mjs:

Type Emitted when
armed Once per run, on driver start. Also written to <runs-root>/index.jsonl so autopilot list / stats can enumerate without scanning every per-run dir.
iteration_start Before spawning the next copilot -p subprocess.
iteration_end After the subprocess exits; carries the iter’s response excerpt and tokens (when usage events were observed).
pause / resume Honored at the next iter boundary after the corresponding flag flip.
stagnation Emitted when the byte-identical-response detector fires.
complete Terminal — completion-promise observed.
abort Terminal — abort-promise / max iterations / stagnation / send error / user stop.

Reader-side helpers live in events.mjs (parseEventLine, serializeEvent, safeSliceChars, foldEvents) and writer.mjs (readRunIndex, aggregateRuns, pruneRuns, the resolveRunsRoot helper).

The emitter swallows every error so a disk hiccup never crashes the loop. The reader is tolerant of malformed lines for the same reason — a torn write must not break replay.

The “baked prompt” pattern

--self-improve and --grow-project are convenience wrappers that drive runner.mjs’s loop with a prompt baked in: the user only supplies optional --focus / --max, and the runner builds a multi-paragraph SDLC or backlog-grooming prompt from a template constant (PROMPT_SELF_IMPROVE, PROMPT_GROW_PROJECT in prompts.mjs).

The baked prompts include hard-coded COMPLETE / ABORT_… tokens. A load-time parity guard at the bottom of prompts.mjs throws if the prompt body stops emitting either token; the runner refuses to start rather than silently ship a broken prompt.

Completion-promise & abort-promise contracts

Pause / resume / stop / status

Out-of-band flags written to the run’s state.json:

autopilot run --pause   <runId>     # set pauseRequested=true
autopilot run --resume  <runId>     # clear pauseRequested
autopilot run --stop    <runId>     # set stopRequested=true
autopilot run --status  <runId>     # read state.json + render

The driver re-reads state.json at every iter boundary; the in-flight Copilot child is never killed mid-iter — the driver waits for natural exit before honoring pause/stop. SIGINT / SIGTERM at the driver process maps onto --stop via the same lock-protected CAS path.

Test architecture

Why we removed the in-session extension

The original project began as an in-session Copilot CLI extension that exposed ralph_loop / self_improve / grow_project tools to the active Copilot session via the @github/copilot-sdk/extension SDK’s joinSession + session.idle event contract. The TUI driver (autopilot run) eventually grew to cover every feature of the in-session tools — same baked prompts, same pause/resume/stop/status, same adaptive budget — without imposing the SDK contract on every cross-cutting change.

Maintaining both engines required keeping every prompt change, every event-vocabulary entry, and every adaptive-budget tweak in lockstep across two implementations. Deleting the in-session engine retired that whole drift surface. See issue #50 for the full rationale.