diff --git a/README.md b/README.md index 022cf42..acfd22f 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,22 @@ rune session read --name reviewer --grep 'THE BOARD' --context 2 ## Architecture & Internals +`run` and `watch` own their child for the length of one call. `session start` hands the child to a +detached supervisor process that keeps its PTY open, serves a control socket, and records a +transcript, so later `rune` calls, from any process, can drive it or read it back. + +```mermaid +flowchart LR + caller["Human or agent"] --> cli["rune CLI"] + cli -->|"run, watch"| pty["Program in a PTY"] + cli -->|"session start"| sup["Detached supervisor
one per session"] + cli <-->|"send, attach, stop
over control.sock"| sup + sup -->|"owns the PTY"| agent["Agent CLI, REPL or shell"] + sup -->|"transcript, meta"| home[("~/.rune")] + cli -->|"read, list"| home +``` + +- 📖 **[High-level design](docs/HLD.md)** — How rune works end to end, with diagrams: components, the three execution models, the session supervisor and its settle logic, on-disk state, security boundaries, timeouts and limits. - 📖 **[Getting Started guide](docs/getting_started.md)** — Output modes, `rune run` usage, timeouts, and parsers with real command output. - 📖 **[Persistent sessions guide](docs/sessions.md)** — `rune session`: named PTY sessions that outlive a single invocation, and send-and-settle for driving one agent CLI from another. - 📖 **[Pseudo-TTY (PTY) Architecture Guide](docs/pty_architecture.md)** — How pseudo-terminals, non-blocking stream reading, ANSI sanitization, prompt detection, script execution, and `rune watch`'s live bidirectional passthrough work under the hood in Ruby. diff --git a/docs/HLD.md b/docs/HLD.md new file mode 100644 index 0000000..6db0ee7 --- /dev/null +++ b/docs/HLD.md @@ -0,0 +1,1000 @@ +# rune: high-level design + +This document describes how rune works end to end, as the code on `main` implements it. It is +the architecture map. The guides stay the place for usage and for the measured behaviour behind +each rule: + +- [Getting Started](getting_started.md): output modes, `rune run`, timeouts, parsers. +- [Persistent sessions](sessions.md): `rune session` in depth, including every documented limitation. +- [PTY architecture](pty_architecture.md): pseudo-terminals, non-blocking reads, `rune watch`. +- [Releasing](releasing.md): versioning, tagging and publication. + +Every statement here should be traceable to a file linked next to it. Where something has not been +established, it says **Unknown:**. Written against `main` at `1f3372d` (version 0.9.0). If the code +and this document disagree, the code and the module specs in [`specs/`](../specs/) win. + +## Contents + +1. [Purpose](#1-purpose) +2. [Context](#2-context) +3. [Components](#3-components) +4. [The three execution models](#4-the-three-execution-models) +5. [Key flows](#5-key-flows) +6. [Session internals](#6-session-internals) +7. [Data](#7-data) +8. [Runtime, build and release](#8-runtime-build-and-release) +9. [Security and trust boundaries](#9-security-and-trust-boundaries) +10. [Failure modes, timeouts and limits](#10-failure-modes-timeouts-and-limits) +11. [Decisions](#11-decisions) +12. [Glossary](#12-glossary) + +--- + +## 1. Purpose + +rune is a Ruby command-line tool and library that runs any terminal program inside a +pseudo-terminal (PTY) and hands back a structured answer. It is built for two kinds of caller at +once: a human at a terminal, who gets coloured output, and an AI agent or script, which gets JSON. +The problem it solves is that terminal programs, and especially full-screen agent CLIs such as +`claude`, `grok` and `codex`, only behave properly when they believe a person is typing at a real +terminal. rune provides that terminal and turns its output into data. Its headline feature, +`rune session`, keeps such a program open across separate calls, so one agent can hold a +conversation with another and a human can take the wheel and hand it back. The product intent is +in [`INTENT.md`](../INTENT.md) and the per-feature criteria in [`hi/`](../hi/). + +## 2. Context + +rune runs entirely on one machine, as the user who invokes it. It makes no network calls. The only +long-lived processes are the per-session supervisors. + +```mermaid +flowchart LR + human["Human at a terminal"] + agent["AI agent or script
(JSON caller)"] + fledge["fledge
(runs bin/rune as a plugin)"] + ext["Other socket client
(any language)"] + + subgraph machine["One machine, one OS user"] + rune["rune CLI
bin/rune"] + sup["Session supervisors
one detached process per session"] + child["Programs in a PTY
shells, git, REPLs, agent CLIs"] + home[("RUNE_HOME, default ~/.rune
meta.json, output.ndjson, control.sock")] + wlog[("watch event log
temp file or --log")] + ps["ps
(process start times)"] + end + + human -->|"run, watch, session attach"| rune + agent -->|"run, session send, read, list"| rune + fledge -->|"fledge rune ..."| rune + rune -->|"run, watch: spawn in a PTY"| child + rune -->|"session start: spawn _supervise"| sup + sup -->|"owns the PTY master"| child + rune <-->|"control.sock, one JSON line each way"| sup + ext <-->|"same socket protocol"| sup + sup -->|"transcript and meta"| home + rune -->|"read and list read files directly"| home + rune -->|"watch appends events"| wlog + rune -->|"list, archive: orphan check"| ps + sup -->|"record child start time"| ps +``` + +The `ps` calls come from [`Store.process_start_times`](../lib/rune/session/store.rb). `rune version` +also runs `which fledge` and `which specsync` to report whether they are installed +([`version_command.rb`](../lib/rune/commands/version_command.rb)). + +## 3. Components + +rune has zero runtime dependencies: only the Ruby standard library (`pty`, `io/console`, +`io/wait`, `socket`, `json`, `timeout` and the like). See +[`rune.gemspec`](../rune.gemspec) and [`Gemfile`](../Gemfile), whose gems are development-only. + +```mermaid +flowchart TB + bin["bin/rune"] --> cli["CLI + Command registry
cli.rb, command.rb, help.rb"] + cli --> rend["Result + Renderer
result.rb, renderer.rb"] + cli --> ver["VersionCommand"] + cli --> runc["RunCommand"] + cli --> watchc["WatchCommand"] + cli --> sessc["SessionCommand"] + + runc --> ptyr["PTYRunner"] + watchc --> ptyw["PTYWatcher"] + + subgraph shared["Shared runtime"] + sig["SignalHandler"] + utf["UTF8StreamDecoder"] + exa["ExecArgv"] + lim["OutputLimiter"] + end + ptyr --> shared + ptyw --> sig & utf & exa + + subgraph session["Session"] + store["Store"] + sup["Supervisor"] + pend["PendingSend + Echo"] + client["Client"] + att["Attachment"] + tr["Transcript"] + pscan["PromptScanner"] + end + sessc --> store & client & att & tr & lim + sessc -. "re-exec as _supervise" .-> sup + sup --> pend & store & pscan & utf & exa + + subgraph parsers["Parsers"] + san["TextSanitizer"] + scr["ScreenRenderer + Screen"] + pd["PromptDetector"] + end + tr --> scr + pscan --> pd + ptyr --> pd & san +``` + +| Module (contract) | Files | Owns | +| --- | --- | --- | +| **cli** ([spec](../specs/cli/cli.spec.md)) | [`rune.rb`](../lib/rune.rb), [`cli.rb`](../lib/rune/cli.rb), [`command.rb`](../lib/rune/command.rb), [`result.rb`](../lib/rune/result.rb), [`renderer.rb`](../lib/rune/renderer.rb), [`help.rb`](../lib/rune/help.rb), [`version.rb`](../lib/rune/version.rb), [`version_command.rb`](../lib/rune/commands/version_command.rb) | Global flags (`--json`, `--ndjson`, `--help`), command registration, help as data, the `Result` envelope, choosing human, JSON or NDJSON rendering, the process exit code. | +| **pty_runner** ([spec](../specs/pty_runner/pty_runner.spec.md)) | [`pty_runner.rb`](../lib/rune/pty_runner.rb), [`run_command.rb`](../lib/rune/commands/run_command.rb), [`script.rb`](../lib/rune/script.rb), [`signal_handler.rb`](../lib/rune/signal_handler.rb), [`utf8_stream_decoder.rb`](../lib/rune/utf8_stream_decoder.rb), [`output_limiter.rb`](../lib/rune/output_limiter.rb), [`exec_argv.rb`](../lib/rune/exec_argv.rb) | `rune run`: spawn, buffered read, timeout, signal forwarding, output bounding, the `Script` DSL, argv-safe exec. | +| **watch** ([spec](../specs/watch/watch.spec.md)) | [`pty_watcher.rb`](../lib/rune/pty_watcher.rb), [`watch_command.rb`](../lib/rune/commands/watch_command.rb) | `rune watch`: raw-mode passthrough, input thread, live output, NDJSON event log, total and idle timeouts. | +| **session** ([spec](../specs/session/session.spec.md)) | [`session/*.rb`](../lib/rune/session/), [`session_command.rb`](../lib/rune/commands/session_command.rb) | `rune session`: storage layout, supervisor process, control socket, send-and-settle, transcript, attach, list, stop, archive. | +| **parsers** ([spec](../specs/parsers/parsers.spec.md)) | [`parsers/*.rb`](../lib/rune/parsers/) | ANSI stripping, the virtual screen that `--screen` renders, prompt-shaped line detection, table and key-value parsing for library users. | + +Each module's spec is enforced by spec-sync at 100% export coverage (see +[section 8](#8-runtime-build-and-release)). Adding a command follows the recipe in +[`AGENTS.md`](../AGENTS.md#adding-a-command). + +## 4. The three execution models + +| | `rune run` | `rune watch` | `rune session` | +| --- | --- | --- | --- | +| Process model | The `rune` process owns the PTY for the length of the call. | Same, plus a background thread that forwards keystrokes. | A detached supervisor process owns the PTY. Every CLI call is a short-lived client. | +| Who types | Nobody. rune's own stdin is not forwarded. The library can pass `input:` or a `Script`. | The human, byte for byte, in raw mode. | `send` (text, then a delayed carriage return), or a human through `attach`. | +| Output | Buffered, one `Result` when the child exits. | Live to the terminal (stderr in agent mode), plus an NDJSON event log. | A reply per `send` holding only that send's output. `read` replays the transcript. Attached terminals see it live. | +| Needs a TTY on stdin | No | Yes | Only for `attach` | +| Ends when | The child exits, or `--timeout` | The child exits, `--timeout`, `--idle-timeout`, or a second INT/TERM | `stop`, or the child exits. Outlives the CLI call that started it. | +| Timeouts | `--timeout` (default 30 s) | `--timeout`, `--idle-timeout` (no default) | `--settle-ms` (800), `--timeout-ms` (120000), start 10 s, stop 3 s + 3 s | +| Log | None | NDJSON event log | `output.ndjson` per session, bounded on disk | +| rune's exit status | The child's exit code | The child's exit code | 0 or 1 per call. The child's status is in `data`. | +| Window size | Not set | Copied from the terminal, followed on resize | 40x120, follows an attached terminal, back to 40x120 when it leaves | + +```mermaid +flowchart LR + subgraph RUN["rune run: one process, returns once"] + r1["rune
PTYRunner"] -->|"PTY master"| r2["child"] + end + subgraph WATCH["rune watch: one process, both directions"] + w0["human terminal
raw mode"] -->|"input thread"| w1["rune
PTYWatcher"] + w1 -->|"PTY master"| w2["child"] + w1 -->|"live output"| w0 + w1 -->|"events"| w3[("NDJSON log")] + end + subgraph SESSION["rune session: short clients, long-lived supervisor"] + s0["rune session send, stop, attach
exits after each call"] -->|"control.sock"| s1["supervisor
own process group"] + s1 -->|"PTY master"| s2["child
own session via PTY.spawn"] + s1 -->|"transcript, meta"| s3[("RUNE_HOME")] + s4["rune session read, list"] -->|"read files"| s3 + end +``` + +All three set `PAGER=cat` and `GIT_PAGER=cat` in the child's environment, so a pager never waits +for a keypress nobody will send. All three treat a missing program as exit 127 and a +non-executable one as 126. The array form of a command is always exec'd directly and never passed +through `/bin/sh` ([`exec_argv.rb`](../lib/rune/exec_argv.rb)). + +## 5. Key flows + +### 5.1 Every command returns a Result + +Commands never print their answer. They return a [`Result`](../lib/rune/result.rb) and the +[`Renderer`](../lib/rune/renderer.rb) formats it. rune's own flags are recognized only before the +first `--`, so `rune run -- gh pr list --json number` hands `--json` to `gh` +([`cli.rb`](../lib/rune/cli.rb)). + +```mermaid +flowchart LR + argv["argv"] --> modes["take --json and --ndjson
from before the first --"] + modes --> help{"help asked for?"} + help -->|yes| hp["Help payload as data"] + help -->|no| cmdcall["command.call(args)"] + cmdcall --> res["Result
status, data, error, exit_code"] + hp --> res + res --> which{"Renderer"} + which -->|"--ndjson"| nd["one NDJSON envelope"] + which -->|"--json, or stdout is not a TTY"| js["JSON envelope"] + which -->|"TTY"| hu["the command's human_render"] + nd --> ex["exit with Result.exit_code"] + js --> ex + hu --> ex +``` + +An exception inside a command becomes `Result.failure`, so a caller always gets an envelope. Session +failures also carry `data.code` (`session_not_found`, `session_not_running`, +`session_already_running`, `session_starting`, `launch_failed`) so a caller can branch without +matching English. The set is open, and an unknown code should be treated as a generic failure +([`session_command.rb`](../lib/rune/commands/session_command.rb), session spec invariant 28b). + +### 5.2 `rune run` + +```mermaid +sequenceDiagram + autonumber + participant A as Caller + participant R as RunCommand + participant P as PTYRunner + participant G as SignalHandler + participant K as Child in a PTY + A->>R: rune run [--timeout] [--max-output or --tail] [--separate-streams] -- cmd + R->>R: parse flags before --, reject a flag-shaped leftover + R->>P: PTYRunner.new(argv, flags).run + P->>K: PTY.spawn with PAGER=cat, exec argv without a shell + P->>G: trap INT and TERM for the run + loop until EIO or EOF, inside Timeout (default 30 s) + G-->>K: forward any queued signal + K-->>P: readpartial 4 KB after wait_readable 0.2 s + P->>P: UTF-8 decode, append, advance Script steps + end + P->>K: Process.wait2 for the exit status + P->>P: strip ANSI, apply --max-output or --tail, prompt_detected from the last line + P-->>R: Result with exit_code, clean_output, raw_output, duration_ms + R-->>A: render, and exit with the child's exit code +``` + +The other endings, all in [`pty_runner.rb`](../lib/rune/pty_runner.rb): + +- **Timeout.** `Timeout.timeout` only interrupts Ruby, so the child is SIGKILLed and reaped with a + bounded wait. The result keeps the output captured so far, says it timed out, and exits 124. If + nothing was captured it adds a hint that `run` does not forward stdin. +- **Repeated signal.** Each INT/TERM is forwarded. A second one within 5 s raises + `SignalHandler::Aborted`: the child gets 1 s to leave, then SIGKILL, and the result exits + `128 + signo` ([`signal_handler.rb`](../lib/rune/signal_handler.rb)). +- **Reaping on macOS.** A PTY child that is SIGKILLed while its output sits unread can wedge + permanently in the kernel. The reap loop drains the PTY master while it polls, which frees it. +- **`--separate-streams`** gives the child a PTY on stdin and stdout but a plain pipe on stderr, and + multiplexes the two with `IO.select`. It cannot be combined with a `Script`. + +### 5.3 `rune watch` + +```mermaid +sequenceDiagram + autonumber + participant H as Human terminal + participant W as PTYWatcher + participant T as Input thread + participant K as Child in a PTY + participant L as NDJSON log + H->>W: rune watch [--log] [--timeout] [--idle-timeout] -- cmd + W->>W: require a TTY on stdin, open the log, print its path on stderr + W->>K: PTY.spawn with PAGER=cat, copy the terminal's size + W->>L: start event + W->>H: enter raw mode + par keystrokes + H->>T: bytes as they are typed + T->>K: write to the PTY master + and output + K-->>W: readpartial 4 KB, polled every 0.2 s + W->>H: write to stdout, or stderr in agent mode + W->>L: output event + end + alt the child exits + W->>L: exit event + else --timeout or --idle-timeout + W->>K: SIGKILL and a bounded reap + W->>L: timeout or idle_timeout event, then exit 124 + else a second INT or TERM within 5 s + W->>K: forward, 1 s grace, then SIGKILL + W->>L: interrupted event, then exit + end + W->>H: restore the terminal, render exit_code, duration_ms, log_path +``` + +The log is a `0600` temp file from `Tempfile.create` unless `--log=PATH` is given. It is not stderr +by default because stderr shares the human's screen. In agent mode (`--json`, `--ndjson`, or stdout +not a TTY) the live view moves to stderr so stdout carries only the envelope +([`watch_command.rb`](../lib/rune/commands/watch_command.rb)). If writing the live view fails with +EPIPE, the child is killed before the watcher returns. + +### 5.4 Session start + +```mermaid +sequenceDiagram + autonumber + participant U as Caller + participant C as rune session start + participant F as RUNE_HOME files + participant S as Supervisor (rune session _supervise) + participant K as Child + U->>C: start [--name x] -- cmd + C->>C: validate the name, check PTY support, early conflict check for an explicit name + C->>F: take start.lock (non-blocking flock) + C->>F: conflict check again inside the lock + C->>F: create dirs 0700, delete old output.ndjson, meta state starting + C->>S: Process.spawn ruby bin/rune session _supervise (own process group, stderr to supervisor.log) + C->>F: meta supervisor_pid + S->>S: Process.setsid (refused for a group leader, rescued) + S->>F: open output.ndjson, bind control.sock 0600 + S->>K: PTY.spawn with PAGER=cat + S->>F: meta state running, child_pid, supervisor_pid, then child_started_at + S->>K: window size 40x120 + S->>F: start event, then the event loop begins + loop every 20 ms, for up to 10 s + C->>F: ready when running with a socket and a live supervisor, or exited + end + alt exec failed, supervisor died, or not ready in time + C->>S: abandon: SIGKILL child group and supervisor, meta failed + C-->>U: status error + else ready + C-->>U: status ok with name, project, child_pid, supervisor_pid, state + end + Note over C,S: the CLI exits here and the supervisor keeps the child +``` + +Details worth knowing, from [`session_command.rb`](../lib/rune/commands/session_command.rb) and +[`supervisor.rb`](../lib/rune/session/supervisor.rb): + +- **Names.** Without `--name`, rune picks an unused `-` codename inside the lock and + retries up to 5 times on contention. With `--name`, a lock that is already held fails as + `session_starting`. +- **Re-exec, not fork.** The supervisor is a fresh `ruby bin/rune session _supervise` process, so it + inherits none of the caller's VM state. `_supervise` is dispatchable but hidden from help. +- **Detachment.** The supervisor is spawned with `pgroup: true`, with stdin and stdout on + `/dev/null`, and `Process.detach`ed. It then calls `Process.setsid`. POSIX refuses `setsid` to a + process that already leads a process group, which `pgroup: true` makes it, and the code rescues + that `EPERM`. Measured on macOS for this document: the supervisor kept the launching shell's + session id, led its own process group, and survived the terminal closing (PTY master closed and + SIGHUP sent to the shell). So on the path measured, it is the separate process group that keeps + a session alive, not `setsid`. **Unknown:** behaviour on Linux and under other shells' hangup + handling. That was not measured. +- **`start` means the supervisor is ready, not the child.** An agent CLI takes seconds to boot, and + input sent before it listens is lost. The `state` in the reply is a snapshot that can already be + stale ([sessions guide](sessions.md#the-loop)). +- **A failed exec is a failure.** Only a `PTY.spawn` that raises means exec failed. The supervisor + records `launch_failed: true`, and `start` returns `status: error` with `code: launch_failed`. A + child that starts and exits at once, even with 127, is a successful launch. + +### 5.5 Send and settle + +This is the core of `rune session`: turning an asynchronous terminal into a request and reply. + +```mermaid +sequenceDiagram + autonumber + participant C as rune session send + participant S as Supervisor + participant P as PTY master + participant K as Child + participant L as output.ndjson + C->>C: parse flags, compile --wait-for-regex, check the supervisor pid is alive + C->>S: connect control.sock, send op send with text, settle_ms, timeout_ms + S->>S: refuse if a send is in flight, the child exited, input is undelivered, or text is missing + S->>P: queue the text (non-blocking outbox) + S->>S: open a PendingSend at the current cursor, schedule the CR 0.25 s out + P->>K: text + K-->>P: echo of the input + P-->>S: pump 4 KB reads + S->>L: output events + S->>S: absorb: locate where the echo ends + S->>P: CR as its own write, once the text has drained and 0.25 s have passed + P->>K: CR (Enter) + K-->>P: the answer + P-->>S: pump + S->>L: output events + loop every tick, 50 ms + S->>S: outcome: regex match, timeout, child exit, or quiet for settle_ms + end + S-->>C: reply with output since the cursor, cursor, settled and its reason flags + C->>C: add clean_output, apply --max-output or --tail, add state from liveness + C->>L: with --screen, load the transcript and render the screen +``` + +Why each step exists, all measured and recorded in the code comments: + +- **The cursor is taken at the send**, so the reply holds only what this send produced, not a banner + or the tail of the previous turn. If the child was still talking when the send landed, the reply + says `busy_at_send: true`. +- **Enter is `\r`, written separately and late.** Raw-mode TUIs listen for `\r`. Agent TUIs treat a + large chunk arriving in one read as a paste, where a carriage return is a newline rather than + submit. Writing the terminator 0.25 s later as its own write, and only once the text has drained, + is what makes long prompts submit (`SUBMIT_DELAY`). +- **Nothing is an answer before the CR has gone out.** Until then only the hard limits (timeout, + child exit) can end the send. A `--no-newline` send schedules no CR, so it counts as submitted at + once. +- **The echo is not an answer.** A PTY echoes what it is given. The settle clock needs output past + the echo, and `--wait-for-regex` never matches inside a copy of the input. See + [section 6.3](#63-the-settle-decision). +- **The client has no timeout of its own.** The supervisor always replies, even on shutdown. The CLI + adds one outer ceiling of `timeout_ms + 15 s` for a wedged supervisor, whose error message points + at `rune session stop`. +- **`--no-wait`** writes and returns `{sent: true, waited: false, cursor}` at once, with no `output`. + +### 5.6 Read and list never touch the socket + +`read` and `list` work from files in the caller's own process. That means they work the same for a +stopped session as for a live one, and cost the supervisor's single thread nothing +([`transcript.rb`](../lib/rune/session/transcript.rb)). + +```mermaid +flowchart TD + read["rune session read --name x
[--since] [--grep] [--tail or --max-output] [--screen]"] + read --> load["Transcript.load: join output events,
record each truncated gap and where it sits"] + load --> since["from(since): map the absolute cursor through the gaps,
snap forward to a character start"] + since --> dangle["hold back an unfinished escape sequence,
from both the text and the cursor"] + dangle --> grep{"--grep?"} + grep -->|yes| g["match ANSI-stripped lines, keep --context lines,
report grep_matches, or grep_error and no output"] + grep -->|no| bound + g --> bound["--max-output: head and tail with a marker line
--tail N: last N lines"] + bound --> fields["cursor, prompt_detected, dropped_bytes"] + fields --> busy["idle_ms and child_busy from the last 8 KB of events"] + busy --> live["state from meta plus a liveness check on the supervisor pid"] + live --> scr{"--screen?"} + scr -->|yes| render["ScreenRenderer over the last 512 KB,
at the rows and cols recorded in meta"] + scr -->|no| out["reply"] + render --> out +``` + +`list` describes each session from `meta.json` plus the last 8 KB of its transcript (`idle_ms`, +`last_line`). State is always recomputed from process liveness, never trusted from the file (see +[section 6.1](#61-session-lifecycle)). `--all-projects` walks every project under `RUNE_HOME`, and +`--archived` lists the archive. For the sessions whose supervisor is gone and whose child's start +time was recorded, `list` runs one batched `ps` and reports `orphaned_child_pid` when that child is +still running. It counts as the same process only when both the pid and the OS start time match. `child_busy` means the child +printed within the default settle window (800 ms). It means *printing*, not *working*. + +### 5.7 Attach and detach + +```mermaid +sequenceDiagram + autonumber + participant H as Human terminal + participant A as Attachment + participant S as Supervisor + participant K as Child + H->>A: rune session attach --name x + A->>A: check the supervisor is alive and stdin is a TTY + A->>S: connect, op attach with the terminal's rows and cols + S-->>A: ack with attached true and the cursor + S->>K: resize to the terminal, SIGWINCH, record rows and cols in meta + S-->>A: replay the last 64 KB of output + A->>H: raw mode, attached banner on stderr + par live output + K-->>S: output + S-->>A: broadcast (queued, dropped past 4 MB undrained) + A->>H: write + and keystrokes + H->>A: bytes, Ctrl-C included + A->>S: raw bytes on the same socket + S->>K: write to the PTY + and resize + H-->>A: SIGWINCH sets a flag, polled + A->>S: a separate connection with op resize + end + H->>A: Ctrl-] (bytes typed before it are still sent) + A->>S: close the socket + S->>K: back to 40x120 once the last terminal leaves + A-->>H: detached, the session is still running +``` + +After the ack the attach socket stops being a request channel and becomes a raw pipe to the PTY, +which is why a resize must travel over its own connection +([`attachment.rb`](../lib/rune/session/attachment.rb)). Ctrl-] is the detach key because agent CLIs +do not bind it, and Ctrl-C must keep reaching the child. If output simply stops, the attachment ends +with an error that points at `rune session list` rather than guessing a cause. + +### 5.8 Stop and archive + +```mermaid +sequenceDiagram + autonumber + participant C as rune session stop + participant S as Supervisor + participant K as Child process group + participant F as meta.json + C->>S: op stop (bounded to 3 s) + S-->>C: stopping true + S->>K: SIGKILL the process group, then wait2 + S->>F: state exited, exit_code (137 for SIGKILL) + S->>S: cleanup: answer any in-flight send with supervisor_exited, drain replies (2 s), remove control.sock, close the log + C->>C: wait up to 3 s for the supervisor to exit + C->>K: SIGKILL the child group and the supervisor, a no-op if gone + C->>C: wait up to 3 s for both pids to disappear + C->>F: state stopped, stopped_at + C-->>C: reply state stopped +``` + +`stop` is idempotent and does not depend on the supervisor cooperating: a wedged one still gets +killed. The child is killed by process group, because agent CLIs start helper processes (node +wrappers, MCP servers) that would otherwise outlive the session. `archive` refuses while the +supervisor is alive, reports any orphaned child pid (this is the last place that pid is visible by +name), then moves the session directory to `archive/-`, freeing the name. + +## 6. Session internals + +### 6.1 Session lifecycle + +The recorded state lives in `meta.json`. It is written by two processes: the CLI (`starting`, +`failed`, `stopped`) and the supervisor (`running`, `exited`). Archiving is a directory move, not a +state write. + +```mermaid +stateDiagram-v2 + [*] --> starting: start, under start.lock + starting --> running: supervisor spawned the child and recorded its pids + starting --> exited: exec failed, launch_failed with 127 or 126 + starting --> failed: not ready within 10 s, or supervisor died + exited --> failed: start saw launch_failed and abandoned it + running --> exited: child exited, stop op, or supervisor crash (70) + running --> stopped: stop had to force-kill a supervisor that never recorded + exited --> stopped: rune session stop + exited --> starting: start again with the same name + stopped --> starting: start again with the same name + failed --> starting: start again with the same name + exited --> archived: archive + stopped --> archived: archive + failed --> archived: archive + archived --> [*] + + note right of running + list, send and read report a resolved state: + running while the supervisor pid is alive, + else the recorded exited or stopped, + else dead + end note +``` + +`dead` is never written. It is what `describe` and `resolved_state` report when the supervisor is +gone and the record does not say `exited` or `stopped`: a SIGKILLed supervisor that left `running` +behind, or a failed start. Starting again with a used name deletes the old transcript first, so the +new supervisor's cursors and `read` offsets describe the same lifetime. + +### 6.2 The supervisor event loop + +One thread, one `IO.select` loop ([`supervisor.rb`](../lib/rune/session/supervisor.rb)). A `send` +has to keep draining the PTY while it waits for the child to go quiet, so a handler that blocked +would deadlock: nothing would read the PTY, the child would stall on a full buffer, and quiet would +never arrive. Every write is non-blocking. Whatever a peer cannot take yet waits in that peer's +outbox until `IO.select` reports it writable. So a child or terminal that stops reading costs +memory, and eventually its own connection, never the session. + +```mermaid +flowchart TD + sel["IO.select, 50 ms
read: server, PTY reader, new clients, attached terminals, in-flight send's client
write: every peer with queued bytes"] + sel --> any{"anything ready?"} + any -->|yes| disp + subgraph disp["dispatch_ready, per ready IO"] + direction LR + a["server:
accept"] + b["PTY reader:
pump into window, fresh, log, broadcast"] + c["client:
read one line (2 s, 1 MB), run op"] + d["attached terminal:
queue bytes for the PTY"] + e["in-flight client at EOF:
drop the send"] + end + disp --> drain["flush writable outboxes"] + any -->|no| submit + drain --> submit["deliver_submit:
write the delayed CR"] + submit --> resolve["resolve_pending:
absorb new output, reply if decided"] + resolve --> reap["close clients silent for 2 s"] + reap --> done{"stopping, or child gone
with no send pending?"} + done -->|no| sel + done -->|yes| conc["conclude: kill the child group if stopping,
record the exit, then cleanup"] +``` + +Two costs are kept proportional to *new* bytes rather than to the whole turn, because anything +quadratic on this thread also starves the PTY drain. The supervisor accumulates `@fresh` as output +arrives instead of re-slicing the transcript. `PendingSend.absorb` folds in only the new bytes. The +measurements that forced this are in [sessions.md](sessions.md#knowing-when-the-other-agent-is-done) +and the comments in [`pending_send.rb`](../lib/rune/session/pending_send.rb). + +The in-memory transcript is a window, not a copy. It keeps the last 64 KB (the attach backlog), or +back to an in-flight send's cursor if that is older. Cursors stay absolute byte offsets into +everything the child ever produced. + +### 6.3 The settle decision + +[`PendingSend`](../lib/rune/session/pending_send.rb) decides when a send has been answered. It does +no IO. The loop gives it the new bytes and the facts it knows (clock, child gone, CR delivered, time +of last output), and it returns an outcome or "keep waiting". Its nested `Echo` class finds the +input in the output, verbatim or *condensed* (escapes and whitespace removed from both sides), so a +colourised, wrapped or repainted echo is still recognized. + +```mermaid +stateDiagram-v2 + [*] --> Waiting: send accepted, cursor taken + state Waiting { + [*] --> Searching: input is not empty + [*] --> Located: input is empty + Searching --> Located: echo found, or 256 KB with no echo + Searching --> Provisional: 0.5 s echo grace passed, nothing found + Provisional --> Located: late echo found and the offer withdrawn, or 256 KB with no echo + } + Waiting --> TimedOut: timeout_ms reached + Waiting --> ChildExited: the child exited + Waiting --> Matched: regex matched outside a repaint of the input + Waiting --> RegexTimedOut: one match ran past 0.25 s + Waiting --> Settled: no regex, output seen, quiet for settle_ms + TimedOut --> [*] + ChildExited --> [*] + Matched --> [*] + RegexTimedOut --> [*] + Settled --> [*] + + note right of Waiting + Before the CR is delivered only + TimedOut and ChildExited can end a send + end note +``` + +*Provisional* output (after the grace window, while the echo is still unfound) can satisfy a +pattern or count as output for the quiet rule, but it is not latched: if the echo turns up late, the +offer is withdrawn and the send goes back to waiting. + +Once the CR is out, outcomes are checked in this order each tick: + +| Order | Condition | Reply flags | +| --- | --- | --- | +| 1 | The regex exceeded its 0.25 s match budget | `settled: false, regex_timed_out: true` | +| 2 | The regex matched, and the match is not inside a copy of the input | `settled: true, matched: true` | +| 3 | `timeout_ms` reached | `settled: false, timed_out: true` (plus `matched: false` for a regex send) | +| 4 | The child exited | `settled: true, child_exited: true` | +| 5 | A regex send with no match yet: keep waiting. Quiet does not answer a regex send. | | +| 6 | Output past the echo has been seen, and none has arrived for `settle_ms` | `settled: true` | + +A supervisor shutting down with a send in flight answers it with `settled: false, +supervisor_exited: true`. The pattern sees at most the last 256 KB past the echo and re-reads 32 KB +behind the previous scan, so any match up to 32 KB long is always found. The reply itself is never +bounded by this. Regex timeouts need Ruby 3.2 or later. On 3.0 and 3.1 a catastrophically +backtracking pattern is a documented limitation. + +The limits of this design, with their measurements, are in the +[sessions guide](sessions.md#knowing-when-the-other-agent-is-done): a child that redraws the input +can settle on the redraw, and a reused pattern can match a reprint of a previous turn. + +### 6.4 Control protocol + +Newline-delimited JSON over the session's UNIX socket: one request line in, one reply line out, then +the supervisor closes the connection once the reply has drained. `attach` is the exception: after +its ack the connection becomes a raw byte pipe. Any language can speak this protocol +([`client.rb`](../lib/rune/session/client.rb), `dispatch` in +[`supervisor.rb`](../lib/rune/session/supervisor.rb)). + +| `op` | Request fields | Reply | +| --- | --- | --- | +| `send` | `text` (required; `""` sends a bare CR), `settle_ms`, `timeout_ms`, `wait_for_regex`, `no_wait`, `no_newline` | `output`, `cursor`, `prompt_detected`, `busy_at_send`, `settled` plus one reason flag, `transcript_gap_bytes` while a gap is owed. With `no_wait`: `sent`, `waited: false`, `cursor`. | +| `status` | none | `name`, `state` (`running` or `exited`), `child_pid`, `supervisor_pid`, `cursor`, `transcript_gap_bytes` while owed | +| `attach` | `rows`, `cols` (optional) | `attached: true`, `cursor`, then the last 64 KB of output and a live duplex stream | +| `resize` | `rows`, `cols` | `resized: true` with the size applied, or `resized: false` with an error | +| `stop` | none | `stopping: true`, then teardown | +| anything else | | `error` (`unknown op`, `malformed request`, or the refusal reason for `send`) | + +The CLI uses `send`, `attach`, `resize` and `stop`. `status` is served for other clients. No `rune +session` subcommand calls it. + +### 6.5 The transcript and its bounds + +Every event the supervisor logs goes through one write path in `log_event`. The rules keep cursors +absolute and the file bounded, even when the disk is full or the directory is unwritable. + +```mermaid +flowchart TD + ev["log_event: output, start, exit or crash"] --> w{"log handle open,
or reopenable by this supervisor?"} + w -->|no| gap["add the output bytes to the owed gap"] + w -->|yes| ceil{"file at the 64 MB hard ceiling?"} + ceil -->|yes| gap + ceil -->|no| owed{"is a gap owed?"} + owed -->|yes| tr["write a torn marker and a truncated event
carrying dropped_bytes"] + owed -->|no| rec + tr --> rec["write the record, one per write"] + rec --> ok{"did the write return?"} + ok -->|no| gap + ok -->|yes| size{"file at 32 MB?"} + size -->|yes| rot["rotate: truncated head event plus the last 8 MB,
written aside then renamed, retried after 30 s on failure"] + size -->|no| done["recorded"] + rot --> done +``` + +- **Absolute cursors.** A `truncated` event carries the byte count that is no longer held. + [`Transcript`](../lib/rune/session/transcript.rb) records where each gap sits, and maps a cursor + through every gap in turn. A cursor that lands inside a gap resolves to what followed it, never to + output already delivered. `read` reports the total as `dropped_bytes`. +- **Torn writes.** A write that fails part-way can leave a fragment. The torn marker written before + the next record makes that fragment unparseable, so the reader skips it rather than counting it + twice. +- **Ownership.** The supervisor will not reopen a transcript whose `meta.json` names a different + supervisor pid, so a supervisor that outlived its session cannot write into a successor's + transcript. +- **The screen.** `--screen` replays the last 512 KB through + [`ScreenRenderer`](../lib/rune/parsers/screen_renderer.rb) at the size in `meta.json`. + `screen_size_recorded` says whether that size was really recorded or is the 40x120 fallback. Sizes + that arrive over the socket are applied to the child as given, but clamped to 300x1000 where they + are recorded, because the recorded size is what later renders allocate + ([sessions guide](sessions.md#it-renders-at-the-size-the-child-is-actually-running-at)). + +## 7. Data + +All session state is plain files under `RUNE_HOME` (default `~/.rune`, see +[`store.rb`](../lib/rune/session/store.rb)). There is no database and nothing on a network. + +```mermaid +erDiagram + RUNE_HOME ||--o{ PROJECT : "projects/slug" + PROJECT ||--o{ SESSION : "sessions/name" + PROJECT ||--o{ ARCHIVED_SESSION : "archive/stamp-name" + SESSION ||--|| META : "meta.json" + SESSION ||--o{ EVENT : "output.ndjson" + SESSION ||--o| CONTROL_SOCKET : "control.sock while served" + SESSION ||--|| START_LOCK : "start.lock, never deleted" + SESSION ||--o| SUPERVISOR_LOG : "supervisor.log" + + PROJECT { + string slug "basename-sha256prefix of the git working tree root" + } + META { + string name + string_array command + string state "starting running exited stopped failed" + float started_at + int supervisor_pid + int child_pid + string child_started_at "ps lstart under LC_ALL=C" + int rows "last applied size, clamped to 300" + int cols "clamped to 1000" + bool size_reduced + int exit_code + float exited_at + bool launch_failed "present only when exec failed" + float stopped_at + float failed_at + } + EVENT { + string event "start output exit truncated crash" + float ts + int bytes "output" + string text "output" + int dropped_bytes "truncated" + int exit_code "exit" + int pid "start" + string command "start" + string error "crash, with message and backtrace" + } +``` + +- **Project scoping.** A project is the enclosing git working tree (the nearest parent holding + `.git`), or the directory itself outside one, with symlinks resolved. The slug is its basename + plus the first 8 hex characters of a SHA-256 of the path, so two worktrees of one repository are + two namespaces. A "no such session" error names the other projects that hold the name. +- **Names.** `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, checked everywhere a name becomes a path, including + the hidden `_supervise` entry point. +- **Permissions.** Directories are `0700` at every level (each component is chmod'ed, because + `mkdir_p` modes are masked by umask) and files are `0600`, because a transcript holds whatever the + agent printed. +- **Atomic meta.** `meta.json` is written to a pid-named temp file and renamed into place, never + truncated, because other processes read it without a lock to answer "does this exist, is it + alive". +- **Socket path length.** Socket paths are capped at 104 bytes on macOS and 108 on Linux. For paths + of 100 bytes or more, bind and connect happen from inside the session directory. +- **The `watch` log** uses the same event shape (`start`, `output`, `exit`, plus `timeout`, + `idle_timeout` and `interrupted`), one JSON object per line with a `ts` field. + +The layout is also drawn in the [sessions guide](sessions.md#where-state-lives). + +## 8. Runtime, build and release + +rune needs Ruby 3.0 or later, and CI tests 3.0, 3.1, 3.2, 3.3, 3.4 and 4.0 on Ubuntu. It needs the +`pty` extension for anything that spawns. Where `pty` cannot load (Windows, some sandboxes) rune +still loads, and `run`, `watch` and `session start` return a `PTY unavailable` failure rather than +crashing ([`pty_runner.rb`](../lib/rune/pty_runner.rb)). This repository's CI runs only on Ubuntu. +The macOS behaviour described in the code comments comes from local measurement, and the Homebrew +tap's own checks cover macOS and Linux ([releasing.md](releasing.md)). + +It is distributed four ways: + +| Channel | How | +| --- | --- | +| Homebrew | `brew install corvidlabs/tap/rune`. The tap's formula builds from the tag tarball and is checksum-pinned. This is the supported install path. | +| fledge plugin | `fledge plugins install CorvidLabs/rune`, then `fledge rune ...`. [`plugin.toml`](../plugin.toml) points fledge at `bin/rune` with the `exec` capability. | +| GitHub Packages gem | Published on release by [`publish-package.yml`](../.github/workflows/publish-package.yml). Its rubygems.org job is disabled (`if: false`), and the `rune` name on rubygems.org belongs to an unrelated gem. | +| Source | `bundle install`, then `ruby bin/rune`. | + +```mermaid +flowchart LR + pr["Pull request"] --> ci["CI: Ruby 3.0 to 4.0
version-check, test, lint"] + pr --> ss["Spec Sync: contracts,
100% coverage, lifecycle"] + pr --> intent["Intent: hi check"] + pr --> trust["trust: verify lane, risk gate"] + ci --> merge["merge to main"] + ss --> merge + intent --> merge + trust --> merge + merge --> prep["release PR: set-version,
CHANGELOG, release lane"] + prep --> tag["fledge release X.Y.Z --no-bump
--no-changelog --pre-lane release --push"] + tag --> ghrel["GitHub release published"] + ghrel --> pub["Publish Gem Package:
tag on main, version match, gem push"] + pub --> pkgs[("GitHub Packages")] + ghrel --> tap["homebrew-tap Bump Rune:
checksum-pinned formula PR"] + tap --> brew[("brew install corvidlabs/tap/rune")] +``` + +Local tasks and lanes are in [`fledge.toml`](../fledge.toml). `fledge lanes run verify` runs +version-check, fmt-check, lint, test, spec-check and spec-lifecycle. `fledge lanes run release` adds +docs-check, smoke-test and the gem build. The Trust workflow +([`trust.yml`](../.github/workflows/trust.yml), configured by [`.trust.toml`](../.trust.toml)) runs +that verify lane, the spec-sync contract at 100% coverage, and an Augur risk gate set to stop at a +`block` verdict (review and block thresholds of 35 and 65 in [`.augur.toml`](../.augur.toml)). Provenance is deliberately off; the +reason is recorded in `.trust.toml` and in [releasing.md](releasing.md). Code changes need a +spec-sync change record; the lifecycle is in [`AGENTS.md`](../AGENTS.md#the-change-lifecycle-is-not-optional). + +At runtime there is no service to deploy. Each live session is one supervisor process. The sessions +guide measured about 23 MB of resident memory and 27 file descriptors per idle session, flat from 24 +to 60 sessions ([sessions guide](sessions.md#what-running-many-at-once-costs)). There is no session +cap by design. + +## 9. Security and trust boundaries + +**The trust boundary is the local OS user.** Anyone who can connect to a session's `control.sock` +can type into its child, and anyone who can read `RUNE_HOME` can read its transcripts. rune protects +both with owner-only permissions (`0700` directories, `0600` files and socket), not with +authentication. It opens no network port. + +What is trusted, and how inputs are checked: + +- **Wrapped commands are the caller's.** rune runs what it is told to. The argv form is always + exec'd and never shelled, so `rune run -- "/opt/my program"` runs that one file and + `rune run -- 'echo A; echo B'` does not run two commands. The library's single-string form is + documented as a shell command line and is shelled ([`exec_argv.rb`](../lib/rune/exec_argv.rb)). +- **Mistyped flags are refused, not passed on.** A flag-shaped token before the command that rune + does not own is an error, instead of being exec'd as a program name or typed at a child + ([`command.rb`](../lib/rune/command.rb)). +- **The child inherits the caller's environment**, plus `PAGER=cat` and `GIT_PAGER=cat`. A session's + child inherits the environment of the `rune session start` call. Secrets in that environment are + visible to the child, as they would be to any program the caller runs. +- **Transcripts can hold secrets.** They record everything the child printed, including anything an + agent echoed back. They are `0600` and rotate at 32 MB, and `archive` keeps them. Delete an + archived session directory to remove its transcript. +- **The supervisor does not trust its socket peers.** Non-object JSON and unparseable lines get an + error reply. A request line must arrive within 2 s and 1 MB. Connections that stay silent are + closed after 2 s. An attached terminal that stops draining is dropped past 4 MB. Resize values are + validated and the recorded size is clamped. No socket peer can crash the event loop or kill the + child (session spec invariant 28). +- **Patterns from callers are bounded.** `--wait-for-regex` runs with a 0.25 s match timeout on Ruby + 3.2 and later. An invalid `--grep` selects nothing rather than everything. +- **Process hygiene.** Children are killed by process group, so their helpers die too. A child is + identified by pid plus OS start time, so a recycled pid is never mistaken for it. Orphans are + reported, never killed: an earlier version that killed on a group-based liveness test killed + unrelated processes ([`session_command.rb`](../lib/rune/commands/session_command.rb), `with_orphans`). +- **`rune run` never forwards its own stdin**, so a caller's terminal or pipe cannot leak into a + wrapped command. +- **CI and release.** Workflows run with `contents: read`. Only the publish job gets `packages: + write`, using the workflow's own `GITHUB_TOKEN`. The rubygems.org job that would use a stored + token is disabled. Publishing refuses a tag that is not an exact `vX.Y.Z` reachable from `main`, + or whose version disagrees with the gem ([`check_release_version.rb`](../scripts/check_release_version.rb)). + Zero runtime dependencies means there is no third-party code in the installed tool. + +## 10. Failure modes, timeouts and limits + +### Timeouts and bounds + +| Where | Value | Constant | What happens | +| --- | --- | --- | --- | +| `run` total | 30 s default | `timeout_seconds:` in `PTYRunner` | SIGKILL, bounded reap, exit 124, output so far kept | +| `watch` total, idle | none by default | `--timeout`, `--idle-timeout` | SIGKILL, exit 124, `timeout_kind` in the result | +| Signal escalation | 2nd INT/TERM within 5 s | `BURST_WINDOW_SECONDS`, `ABORT_AFTER` | 1 s grace, SIGKILL, 2 s reap bound, exit `128 + signo` | +| PTY read | 4 KB chunks, 0.2 s poll | `readpartial(4096)`, `wait_readable(0.2)` | Signals and timeouts are serviced while the child is quiet | +| Session start | 10 s | `START_TIMEOUT` | Not ready: abandon, meta `failed`, error | +| Settle window | 800 ms | `DEFAULT_SETTLE_MS` | Quiet this long after output past the echo answers a send | +| Send cap | 120 000 ms | `DEFAULT_TIMEOUT_MS` | `timed_out: true` with the output so far. A result, not a failure. | +| CLI ceiling on a send | `timeout_ms` + 15 s | `CLIENT_TIMEOUT_MARGIN` | Error that suggests `rune session stop` | +| Enter delay | 0.25 s | `SUBMIT_DELAY` | CR written separately, after the text drains | +| Echo grace | 0.5 s | `ECHO_GRACE_SECONDS` | After this, unplaced output is offered provisionally | +| Regex budget | 0.25 s per match | `REGEX_MATCH_TIMEOUT` | `regex_timed_out: true` (Ruby 3.2+ only) | +| Regex window | 256 KB, 32 KB span | `MATCH_WINDOW_BYTES`, `MATCH_SPAN` | A match longer than 32 KB is never found | +| Event loop tick | 50 ms | `POLL_INTERVAL` | | +| Control request | 2 s, 1 MB | `REQUEST_READ_TIMEOUT`, `MAX_REQUEST_BYTES` | Connection closed | +| Attached terminal backlog | 4 MB | `MAX_OUTBOX_BYTES` | That terminal is dropped. Control replies are not capped. | +| Attach replay | 64 KB | `ATTACH_BACKLOG_BYTES` | | +| Reply drain at teardown | 2 s | `REPLY_DRAIN_TIMEOUT` | | +| Stop | 3 s graceful + 3 s death wait | `GRACEFUL_STOP_TIMEOUT`, `DEATH_TIMEOUT` | Force-kill regardless | +| Transcript file | rotate at 32 MB to the last 8 MB, stop at 64 MB | `MAX_LOG_BYTES`, `LOG_KEEP_BYTES`, `HARD_LOG_CEILING` | `dropped_bytes`, cursors stay absolute | +| Failed rotation | retry after 30 s | `ROTATE_RETRY_SECONDS` | Recording continues into the oversized file | +| Screen render | last 512 KB, 40x120 default | `DEFAULT_TAIL_BYTES`, `DEFAULT_ROWS/COLUMNS` | Content painted once and never repainted can be missing | +| Recorded window size | 300 x 1000 | `MAX_ROWS`, `MAX_COLUMNS` (supervisor) | Clamped at the record, `screen_size_recorded: false` | + +### What breaks, and how it degrades + +- **Settle is a heuristic.** A child that pauses mid-answer longer than `--settle-ms` returns a + partial answer. A line editor that repaints the input on submit (`irb`, `python3`) can settle on + the repaint. Nothing in the reply tells these apart from a real answer. Use `--wait-for-regex` + with a sentinel unique to the turn, or a file the child writes. +- **A reused `--wait-for-regex` pattern can match a reprint** of an earlier turn from a TUI that + redraws its scrollback. The echo veto only covers this send's input. +- **An unterminated line of 1024 bytes wedges the session.** This is the tty's canonical-mode line + limit, not rune's, as measured in the sessions guide. Every later send is silently discarded while + replies say `settled: true`. Ctrl-U (`\x15`) recovers it. Chunk input that may exceed 1023 bytes. +- **A polled `--screen` can return a half-painted frame.** Not fixed. Two candidate fixes measured no + better than doing nothing. +- **`prompt_detected` is advisory.** It matches shell-shaped last lines, and agent REPLs mostly do + not look like that. Never gate on it. +- **One send at a time per session.** A second is refused (`a send is already in flight`). A caller + that disconnects mid-send frees the session at once. The supervisor notices the EOF. +- **Backpressure.** If a `--no-wait` send's text has not drained, the next send is refused (`previous + input is still being delivered to the child`) rather than merged into one read. +- **Supervisor death.** A SIGKILLed supervisor never updates `meta.json`. Every reader therefore + recomputes liveness from the OS, and reports `dead`. If its child survived, `list` and `archive` + report the orphan's pid. A supervisor that crashes on an exception logs a `crash` event, records + exit 70 (`EX_SOFTWARE`), and kills the child during teardown. A teardown that reaches cleanup with + no exit recorded kills the child before recording one. +- **A wedged supervisor.** The CLI's outer ceiling turns a hang into an error, and `stop` does not + depend on the supervisor cooperating. +- **A full disk or an unwritable directory.** The session keeps running. Lost output is carried as + an owed gap and recorded when writing resumes. The hard ceiling keeps the file bounded even if + rotation keeps failing. Until something can be written, the gap appears only as + `transcript_gap_bytes` on `status` and `send`. +- **Wrong directory.** Sessions are scoped per project, so `read` from another checkout answers "no + such session" and names the project that holds it. `list --all-projects` shows everything. + +The complete list, with the measurement behind each item, is in the +[sessions guide](sessions.md#what-to-know-before-driving-a-real-agent), the session spec's +[Known Limitations](../specs/session/session.spec.md#known-limitations) and +[`ROADMAP.md`](../ROADMAP.md#known-and-documented-not-planned-for-10). + +## 11. Decisions + +There is no `DECISIONS.md` or ADR directory. Decisions are recorded in four places: + +- The module specs in [`specs/`](../specs/): the *Invariants*, *Known Limitations* and *Change Log* + sections of each `*.spec.md`. +- SpecSync change records: active ones in [`.specsync/changes/`](../.specsync/changes/) and archived + ones in [`.specsync/archive/changes/`](../.specsync/archive/changes/). Each has its requirements, + design and verification. +- [`CHANGELOG.md`](../CHANGELOG.md), which carries the measurement behind each change, and + [`ROADMAP.md`](../ROADMAP.md), which records what is gating 1.0, what shipped as a documented + limitation, and what is deliberately not planned. +- Long comments at the decision point in the code, which are often the most detailed record. The + measurement harnesses behind the numbers are kept in [`harnesses/`](../harnesses/README.md). + +The decisions that shape the architecture: + +1. **Three execution models in separate classes.** `PTYWatcher` is not a mode of `PTYRunner`, + because raw mode and an input thread are a different execution model, and `run`'s contract + stays fixed ([pty_architecture.md](pty_architecture.md#6-live-interactive-passthrough-ptywatcher--rune-watch)). +2. **Every command returns a `Result`; rendering is separate.** One tool serves humans and agents + with the same commands ([`AGENTS.md`](../AGENTS.md)). +3. **One process per session, started by re-exec rather than fork.** Isolation means a wedged agent + takes down only its own session. The price is a Ruby interpreter per session, measured and + documented. +4. **A single-threaded supervisor on `IO.select`, where nothing blocks on a write.** No threads, no + locks, and no way for one slow peer to stall the PTY drain ([`supervisor.rb`](../lib/rune/session/supervisor.rb)). +5. **A language-neutral control protocol, and reads from files.** NDJSON over a UNIX socket for + commands. `read` and `list` go to the transcript on disk so they work after a session ends. +6. **Enter is a carriage return, written separately, 0.25 s after the text.** It was measured against + real agent TUIs. The delay was raised from 0.05 s after it failed against Kimi. +7. **An 800 ms settle default, re-measured.** 0.4.0 raised it to 3000 ms on a measurement later + found wrong twice. It was measured again with both harness bugs fixed and returned to 800. +8. **Liveness comes from the OS, never from the recorded state**, and `meta.json` changes only by + atomic rename. +9. **Bounded memory and disk, with absolute cursors.** An in-memory window, rotation, a hard ceiling + and gap accounting, so a session left running for a day does not grow without limit and no cursor + ever re-delivers old output as new. +10. **Report orphans, never kill them**, because the kill-based version killed strangers. +11. **A documented limitation beats an unproven fix.** Several candidate settle and screen fixes + were measured, rejected and written down instead + ([`AGENTS.md`](../AGENTS.md#how-work-is-judged-here), [`ROADMAP.md`](../ROADMAP.md)). +12. **Zero runtime dependencies, and provenance off with the reason recorded** + ([`.trust.toml`](../.trust.toml), [releasing.md](releasing.md)). + +## 12. Glossary + +| Term | Meaning | +| --- | --- | +| PTY, pseudo-terminal | A kernel pair of devices. The child gets the slave side as its terminal. rune holds the master side, reads the child's output from it and writes input into it. | +| Cooked and raw mode | In cooked (canonical) mode the tty buffers a line and echoes it, and a line has a fixed limit (measured at 1024 bytes in the [sessions guide](sessions.md#what-to-know-before-driving-a-real-agent)). In raw mode each byte goes straight through. Most agent TUIs run raw. | +| Echo | The copy of your own input that the terminal, or the child, prints back. It is never an answer. | +| Supervisor | The detached `rune session _supervise` process that owns one session's PTY and serves its socket. | +| Send and settle | Write input, wait until the child has answered, and return only that answer. | +| Settle | Answered by quiet: output past the echo, then nothing for `--settle-ms`. | +| Cursor | An absolute byte offset into everything a session's child has produced. It stays valid across rotation and dropped regions. | +| Transcript | A session's `output.ndjson` file: every output event, plus `truncated` events for what was dropped. | +| Screen | What a terminal would be showing, rendered from the transcript by `ScreenRenderer`. | +| Project | A session namespace: the git working tree (or directory), named by its basename and a path hash. | +| Codename | A generated `-` session name, used when `--name` is omitted. | +| Orphan | A session's child that is still running after its supervisor is gone, identified by pid plus OS start time. | +| Outbox | The supervisor's per-peer queue of bytes waiting for the peer to become writable. | +| Gap | Output the transcript could not record (rotation, a failed write, the hard ceiling), accounted for by `truncated` events. |