AGENTS.md · diff
git:20261018.e59d298 to git:20261018.f426746
9 added, 15 removed. Audit A to A.
# 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 ~44k-line crate with **~5,360 unit tests** (5,354 under
`cargo test --lib` on this machine) and **144 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` (all 5,354) | ~44 s | once, before you call the work done |
| `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, 22 integration binaries) | **~2 m 20 s** | once, at the end of a change that spans crates/seams |
| `scripts/smoke.sh <id>` (one phase) | ~2–20 s | you touched the terminal boundary |
| `scripts/smoke.sh` (all 144) | minutes, parallel | **only** for `src/tui/`, `term.rs`, rendering/geometry |
| `cargo build --release` / `--example …` | 40–60 s+, and `target/` is already ~10 GB | only when the task explicitly needs a release artifact |
`target/` is ~10 GB and 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/`, `term.rs`, `src/main.rs` | **no unit tests exist here** — `cargo build && scripts/smoke.sh <relevant id or range>` |
| 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 --test cli_help` |
| `src/subprocess.rs` detach, `src/pty/` | `cargo test --test detached_exec` |
| `src/llm/mcp*`, the `mcp` CLI | `cargo test --test mcp_cli` |
| a memory-sensitive path (per-turn/per-request/per-picture allocation) | the matching `tests/*_memory.rs` gate (`image_turn_memory`, `model_parse_memory`, `image_paste_memory`, `write_preview_memory`) |
| `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` executes 1,059
real assertions 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.
libtest takes **one** filter per invocation, and module filters are prefixes, so
prefer the wider one: `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. Don't chain two narrow filters; run the prefix that contains
both. When you want nearly everything except the environment-dependent modules,
`cargo test --lib -- --skip pty:: --skip background:: --skip llm::exec::` runs
4,833 assertions 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.
| | 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 |
| tests that exist | 5,389 | **5,354** in `--lib` — 35 are `cfg(target_os = "linux")`, and 10 live in `src/tui/` (bin-only, never in `--lib`) |
| `scripts/smoke.sh` | bash 5 from the distro | needs **bash 4+**; macOS `/bin/bash` is 3.2 and dies |
- | `--list`, phase titles/tags | works | **BSD `sed` breaks both** (see below) |
+ | `--list`, phase titles/tags | works | works — the readers were BSD-`sed`-broken once; `selftest.sh` now pins them |
| `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 |
| `cargo doc --no-deps --lib` | clean | **fails on `origin/main`**: `src/pty/spawn.rs` links `super::probe::group_program`, which is `cfg(target_os = "linux")` |
**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.
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. Homebrew's bash is on `PATH` here, so `scripts/smoke.sh` works; if it
ever stops, `bash --version` is the first thing to check.
- - **`scripts/smoke.sh --list`, title selection and the `serial` tag are broken
- on BSD `sed`** — `smoke_phase_title` and `smoke_phase_tags` both fail there.
- Two real consequences, not cosmetic:
- - **A multi-word title query quietly widens to an OR.** With the title empty
- the selector matches the slug only, so `scripts/smoke.sh "the first turn"`
- also selects `111b theme` (`the` ⊂ `theme`). It runs phase 1 *and* the theme
- phase, which looks like an unrelated failure.
- - **`121-slowstream` loses its `serial` tag**, so a timing-sensitive phase is
- scheduled into the parallel pool where a loaded machine can skew it.
-
- Select by **id** (`scripts/smoke.sh 9`) or a distinctive slug word
- (`scripts/smoke.sh quit`), and check `--list` before trusting a multi-word
- query. Running a phase file directly is unaffected:
- `bash scripts/smoke/phases/009-quit.sh`.
+ - **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. 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.
- **`cargo doc --no-deps --lib` fails on macOS, on `origin/main`, for a reason
that is not yours.** `src/pty/spawn.rs` links `[`super::probe::group_program`]`
and that function is `cfg(target_os = "linux")`, so off Linux the intra-doc
link has no target and the crate denies warnings. It is the **only** doc error
here, and CI (Linux) is unaffected.
So the doc gate cannot be observed locally on a Mac. Two honest options: run
it in the Linux container/CI, or read the error list and confirm yours is not
in it — `cargo doc --no-deps --lib 2>&1 | grep '^error'` prints exactly two
lines here, the known unresolved link and the `could not document` summary.
Do **not** delete the link to make it pass: that edits a platform-gated
module and belongs in its own PR. `--document-private-items` does not help;
it surfaces different, unrelated unresolved links.
- **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` (39 s to
rebuild here, and it keeps every dependency) fixes it — do **not** reach for
`rm -rf target/`.
- Every test that reaches the network, a real clipboard or a real X server is
`#[ignore]`d (in `tests/live_*.rs`, `tests/clipboard_*.rs`,
`tests/wire_history.rs`, `tests/write_preview_memory.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 that break the build if ignored
- 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).
- No emoji in code or comments.
## 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`, `docker/build.sh`, `makepkg`, or anything that
publishes, builds images, or pushes — those are the user's calls.
- 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.