AGENTS.md · git:20261021.908f018 · 2026-10-21 · sha256 5cdc1243704d1ebe

AGENTS.md git:20261021.908f018A

Immutable. This exact content never changes and is served at /api/v1/blob/5cdc1243704d1ebe.

# AGENTS.md

Instructions for coding agents (**Claude Code**, Codex, Copilot, Cursor, anything
that reads `AGENTS.md`) working in this repository. `CLAUDE.md` is read by Claude
Code too; where the two disagree, this file wins on **what to run**, `CLAUDE.md`
wins on **how the code works**.

Read [`CLAUDE.md`](CLAUDE.md) (the architecture map) and the `docs/` page for the
area you are touching before changing it. `docs/` holds the design rationale —
what looks arbitrary in this code is usually a bug someone already paid for.

## The most important rule: run the smallest check that can fail

This is a single crate of about 250k lines of Rust (some 80k of it outside the
tests) with **about 5,500 unit tests** and **146 smoke phases**. The full suite
is not a "does it still work" button — it is the last step before a commit, and
CI runs it for you on every push.

Do **not** run `cargo test` (or `cargo clippy --all-targets`, or
`scripts/smoke.sh`) after every edit to see whether something broke. Pick the
targeted command for the code you touched, and escalate only when the change
crosses a boundary.

### What each command actually costs here

Measured on an 8-core Apple-silicon Mac, warm `target/` (not a cold checkout):

| Command | Wall time | Run it when |
| --- | --- | --- |
| `cargo check --lib` | ~6 s | after any edit, if you only want "does it compile" |
| `cargo test --lib <module>::` | ~0.4 s if nothing rebuilt, **~9 s after an edit** (recompile + link) | default loop after every edit in the library |
| `cargo test --lib` (every unit test) | ~44 s | a full unit pass mid-change |
| `cargo test --test <name>` | ~12–35 s (rebuilds the bin for `CARGO_BIN_EXE`) | you touched a binary-facing seam (see matrix) |
| `cargo fmt --check` | ~2 s | before commit |
| `cargo clippy --all-targets -- -D warnings` | ~23 s warm | before commit, or after touching a lint-visible shape |
| `cargo doc --no-deps --lib` | ~4 s | before commit **or** after moving/renaming a public item |
| `cargo test` (everything, every integration binary) | **~2 m 20 s** | once, before you call the work done (it is the gate's test step) |
| `scripts/smoke.sh <id>` (one phase) | ~2–20 s | you touched the terminal boundary |
| `scripts/smoke.sh` (every phase) | minutes, parallel | **only** for `src/tui/`, `term.rs`, rendering/geometry |
| `cargo build --release` / `--example …` | 40–60 s+, plus a second profile in `target/`, which grows to ~10 GB | only when the task explicitly needs a release artifact |

`target/` grows to ~10 GB and is incremental. Deleting it to "clean up" costs a
multi-minute full rebuild — don't, unless the user asks.

### Which tests to run after a given change

| You changed | Run this |
| --- | --- |
| `src/app/<area>.rs` (pure state) | `cargo test --lib app::tests::<area>` — the tests live beside the code, often in `src/app/tests/<area>.rs` |
| `src/ui/<area>.rs` (renderers, theme) | `cargo test --lib ui::` — plus one smoke phase if the change is visible |
| `src/llm/<provider or wire>` | `cargo test --lib llm::` — plus the live test for that provider **only if the user asks** (`--ignored`, spends real tokens) |
| `src/tui/`, `src/main.rs` | the boundary is verified on screen: `cargo build && scripts/smoke.sh <relevant id or range>` — plus `cargo test --bin alter-zero` for the few pure helpers tested in `src/tui/` |
| `src/term.rs` | `cargo test --lib term::` for its pure helpers (the cell emitter, the startup-read scanner), then the smoke phases that paint what you changed |
| anything on screen (spacing, glyphs, colours, cursor) | one or two smoke phases; the whole suite only at the end |
| a public item moved/renamed, or a `mod.rs` re-export | `cargo test --test api_surface` + `cargo doc --no-deps --lib` |
| `src/cli.rs`, `--help`, arg parsing | `cargo test --lib cli::` + `cargo test --test cli_help` |
| `src/pty/`, `src/subprocess.rs` | `cargo test --lib pty::` or `subprocess::` — plus `cargo test --test detached_exec` when the detach or the helper re-exec changed |
| `src/mcp/`, `src/llm/mcp/`, the `mcp` CLI | `cargo test --lib mcp::` (the filter matches both modules) + `cargo test --test mcp_cli` for the CLI |
| a memory-sensitive path (per-turn/per-request/per-picture allocation) | the matching `tests/*_memory.rs` gate (`ls tests/*_memory.rs`) |
| `providers.toml`, `src/llm/config.rs` | `cargo test --lib llm::config` — it pins the shipped provider set and the `snake_case` spellings |
| `scripts/smoke/lib.sh`, `scripts/smoke.sh` (the phase readers or the selector) | `bash scripts/smoke/selftest.sh` — under a second, no app, no tmux |
| `scripts/smoke/phases/`, screen behaviour | `cargo build && scripts/smoke.sh <ids>` — by **id**, and widen only if you must |
| `scripts/`, `install.sh`, `npm/`, `telemetry/`, `docker/`, `aur/` | that subtree's own suite (see [`CONTRIBUTING.md`](CONTRIBUTING.md) *Testing*) — not `cargo test` |
| `prompts/`, `docs/`, `CHANGELOG.md` | nothing; prose has no gate |

A filtered run is not a compromise: `cargo test --lib app::tests` runs about
1,060 tests in ~0.4 s once the binary is built, and while the tree is being
edited the only cost is the ~9 s relink. The expensive part is the build, not the
tests.

A filter matches any test whose path contains it, so a module path selects
everything under it: `cargo test --lib app::tests` covers every area under
`src/app/`, where `cargo test --lib app::tests::login` covers one file — and the
two cost the same. Prefer the wider filter to two narrow runs; when the areas
don't share a prefix, pass several filters in one run after `--`
(`cargo test --lib -- app::tests::login term::`), and a test any of them
matches runs. Because filters are substrings, `--skip background::` would also
skip the pure `app::tests::background` tests, so when you want nearly everything
except the environment-dependent modules,
`cargo test --lib -- --skip pty:: --skip background::tests:: --skip llm::exec::`
runs about 4,950 tests in ~20 s.

### Before you say the work is done

The project's gate (also what `.github/workflows/ci.yml` runs for you on push):

```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test                 # once, at the end
cargo doc --no-deps --lib
```

`Cargo.toml`'s `[lints]` table already denies `warnings`, `clippy::all` and
`unsafe_code`, so a warning is a build failure locally, not just in CI. The doc
build is part of the gate because a broken intra-doc link is an error here: a
public item's docs may not link to a private one, so moving an item between
modules breaks the links that pointed at it.

## Linux vs macOS — what actually differs

CI runs the gate and the smoke suite on `ubuntu-latest`, so **Linux is the
reference**: `cargo test --lib` there is expected to be green, and any red is
yours. On macOS a red run usually is not. Know which platform you are on before
you diagnose anything. (CI's two macOS jobs run only the clipboard tests and the
smoke readers' selftest.)

| | Linux (CI) | macOS |
| --- | --- | --- |
| `cargo test --lib` | green | **6 baseline failures** with `setsid` on `PATH`, **46** without it |
| `setsid` | ships with util-linux | **absent**; `brew install util-linux`, then put it on `PATH` |
| `ps … sid` | supported | **no `sid` keyword** — one test can never pass here |
| `--lib` tests compiled | about 5,460 | about 5,430 — **35** are `cfg(target_os = "linux")` and never compile here, **6** (`clipboard/macos.rs`) compile only here; the 10 in `src/tui/` are bin-only on both |
| `scripts/smoke.sh` | bash 5 from the distro | needs **bash 4+**; macOS `/bin/bash` is 3.2 and dies |
| `--list`, phase titles/tags | works | works — the readers were BSD-`sed`-broken once; `selftest.sh` pins them, and CI runs it on macOS |
| `timeout`, `nproc`, `grep -P` | present | **absent** (`smoke.sh` already falls back to `sysctl -n hw.ncpu`) |
| a stale incremental link | rare | **`Undefined symbols: … (.llvm.…)`** after a branch switch |

**35 tests never run on macOS.** They are not skipped-and-reported; the Linux
`cfg` compiles them out, so nothing in the output mentions them. By file:
`llm/exec.rs` 17, `pty/probe.rs` 8, `clipboard.rs` 4, `subprocess.rs` 4,
`background.rs` 1, `pty/spawn.rs` 1. The modules themselves (`pty/probe`,
`clipboard/linux`) have Linux-only bodies, and `tests/clipboard_linux.rs` is
`#![cfg(target_os = "linux")]` — it compiles to **0 tests** on a Mac, silently.
The reverse holds for `clipboard/macos.rs`'s 6 unit tests and
`tests/clipboard_macos.rs`: Linux never compiles them, and CI's
`clipboard (macOS pasteboard)` job is what runs them.

So a green macOS run says nothing about: the `/proc`-based "is it waiting for
input" probe, the streamed `image/png` clipboard read, `setsid -c` detaching,
or the Linux half of the executor. Say in the commit which platform you
actually ran. Those areas need the Linux CI run (or a container) to mean
anything.

### macOS: the six baseline failures

Do not chase them, do not "fix" them, and do not report them as regressions.
They need a real Linux/tty environment or a `setsid` binary:

- `pty::spawn::tests::the_session_leads_its_own_group_and_session` — macOS `ps`
  has no `sid` keyword.
- `llm::exec::tests::kill_asks_the_command_to_stop_before_it_insists`,
  `…::indented_lines_reach_a_program_that_takes_pastes_as_one_paste`,
  `…::a_key_name_sent_without_brackets_is_the_key_in_a_full_screen_program` —
  SIGTERM/paste timing through the pty.
- `background::tests::a_background_display_that_reads_whole_lines_is_never_told_of`,
  `…::a_picker_reading_its_terminal_waits_however_its_line_animated`.

Without `setsid` on `PATH`, the count is **46**: every tty-spawning test fails
with `no way to give the command a terminal of its own here`. macOS ships no
`setsid`; util-linux does. Put it on `PATH` before running the lib suite:

```bash
PATH="/opt/homebrew/opt/util-linux/bin:$PATH" cargo test --lib
```

A green lib run with the six skipped:

```bash
PATH="/opt/homebrew/opt/util-linux/bin:$PATH" cargo test --lib -- \
  --skip background::tests::a_background_display_that_reads_whole_lines \
  --skip background::tests::a_picker_reading_its_terminal_waits \
  --skip llm::exec::tests::a_key_name_sent_without_brackets \
  --skip llm::exec::tests::indented_lines_reach_a_program \
  --skip llm::exec::tests::kill_asks_the_command_to_stop \
  --skip pty::spawn::tests::the_session_leads_its_own_group
```

Other platform notes:

- **`scripts/smoke.sh` needs bash 4+** (`declare -A` at its worker table).
  macOS `/bin/bash` is 3.2, so running it under that shell dies with
  `declare: -A: invalid option` and `TOTAL: unbound variable` — not a phase
  failure. Put a newer bash first on `PATH` (`brew install bash`); when the suite
  dies like this, `bash --version` is the first thing to check.
- **The smoke suite's own tests are `bash scripts/smoke/selftest.sh`** — the
  phase readers and the selector, over fixture headers, in under a second. Run
  it after touching `scripts/smoke/lib.sh` or `scripts/smoke.sh`; CI runs it
  before the phases and again on macOS under BSD `sed`. It exists because those
  readers fail *silently*: they decide which phases a query runs, so a reader
  that returns nothing runs the wrong suite rather than failing.
- **A stale incremental build can fail to link** with
  `Undefined symbols: … (.llvm.…)` after switching branches. That is
  incremental codegen, not your change. `cargo clean -p alter-zero` fixes it
  and keeps every dependency, so the rebuild is short — do **not** reach for
  `rm -rf target/`.
- Every test that reaches the network, a real clipboard or a real X server is
  `#[ignore]`d (`tests/live_*.rs`, `tests/clipboard_*.rs`, and two in
  `tests/wire_history.rs`). `cargo test` is
  offline and deterministic — keep it that way. Never add `-- --ignored` to a
  routine run: those tests need API keys and **spend real tokens** on whatever
  account the key belongs to. The clipboard ones also overwrite your real
  pasteboard / need an X server.
- Smoke phases each get their own tmux server, config home, `$HOME` and temp
  tree, so they neither see nor disturb your real `~/.alter-zero`, clipboard or
  `/resume` list.

### What a platform-blind diagnosis gets wrong

Two failure modes to name, because both look like your bug and are not:

- **"My change broke 46 tests"** on a fresh Mac — it did not; `setsid` is
  missing. Confirm with `command -v setsid` before reading a single stack trace.
- **"It passes on macOS, ship it"** for anything in `pty/probe`, `exec`,
  `subprocess`, `background` or `clipboard` — half of that code is behind
  `cfg(target_os = "linux")` and macOS never compiled it. Those need the Linux
  CI run (or a container) to mean anything.

## Working style (standing instructions, not suggestions)

From [`CONTRIBUTING.md`](CONTRIBUTING.md), unchanged:

- **TDD is rigid**: no production code before a failing test, and you must see
  the test fail for the right reason. The only exception is `src/main.rs` +
  `src/tui/` — the terminal I/O boundary, verified by running the app and the
  smoke suite instead.
- **Design before implementing** for a feature or non-trivial change: explore,
  ask your questions one at a time, propose 2–3 approaches, agree, then write it
  down in `docs/` (and update `docs/design.md` when behaviour changes). Trivial
  fixes need no ceremony — the failing test still does.
- **One logical change per commit**, message a reader can act on. Don't bump the
  version or edit a released changelog section.
- Intent lives in [`docs/`](docs/), user-visible changes in `CHANGELOG.md`, and
  the README stays a front door. A `docs/` page that no longer matches the code
  is a bug.

## Conventions

- All styling is centralized: glyphs/geometry as `const`s in `src/ui/theme.rs`,
  colours as accessors reading `src/ui/palette.rs`. A new colour is a new role on
  `Palette` in **every** theme table plus its accessor — never a literal
  `Color::Rgb` outside `palette.rs`.
- Width math goes through `cols()` (display columns), never `chars().count()`;
  every cell handed to the backend goes through `term::visible_cells`.
- The input line is a `textarea::TextArea`, not a `String`.
- Never build a `Value` tree for a body you read a few fields from, and never
  decode a picture whole to make a small one — resident memory is a feature
  here ([`docs/memory.md`](docs/memory.md), the `tests/*_memory.rs` gates).
- `unsafe_code = "forbid"`. The terminal and the pty are reached through safe
  wrappers (`rustix`), not `pre_exec`.
- Keep logic out of the I/O boundary: when `tui/` needs a decision, the decision
  belongs in a pure helper it calls (the live-region geometry works this way).

## Don't

- Don't run the whole `cargo test` or the whole smoke suite "to check" after a
  small edit — see the table above.
- Don't `rm -rf target/`, don't re-enable/disable `incremental`, and don't touch
  `[profile.*]` or `rust-toolchain.toml` to "make it faster". The toolchain pin
  exists so the gate is reproducible; a floating `stable` breaks the build.
- Don't leave a second filtered `cargo test` in the same edit; widen the filter.
- Don't run `scripts/release.sh prepare`, `build`, `npm` or `download`,
  `docker/build.sh`, `makepkg`, or anything else that bumps a version,
  publishes, builds images, or pushes — those are the user's calls.
  (`scripts/release.sh selftest` and `check` are the release tooling's own
  tests; run them when you touch it.)
- Don't add `#[ignore]` to a failing test, or `#[allow(...)]` to silence a lint,
  in order to get green. Fix the cause or say you are blocked.