anvilsign in

collin/anvil

main / docs / agent-sessions.md

RenderedSource

1# Agent sessions
2
3An **agent session** is a long-lived container running tmux plus an agent CLI
4(Claude Code) against one repository, attached from a terminal in the browser.
5
6It is off by default. Turn it on with `agent.enabled` in `anvil.toml`, and read
7[untrusted-mode.md](untrusted-mode.md) §7 first — a session is a genuinely
8different exposure from CI.
9
10```
11 browser (wterm) <--websocket--> anvil-web
12 |
13 docker exec (tty)
14 v
15 supervisor ---- tail -F ----> [ container: tmux -> claude ]
16 | |
17 '--> transcript on disk pipe-pane
18```
19
20## Why tmux
21
22With a long-lived container you could just `docker exec -it` a shell. tmux earns
23its place for four reasons, in order of importance:
24
251. **A dropped websocket must not kill the agent.** An exec session is tied to
26 its client. The agent is a process *inside* tmux rather than a child of the
27 exec, so closing the tab detaches a client and nothing more. This is the
28 decisive one.
292. **`tmux pipe-pane`** gives a byte-exact transcript that keeps recording
30 whether or not anyone is watching — the storage story for autonomous runs.
313. **tmux is already the multiplexer.** Several browsers attach to the same
32 session and tmux repaints each one, so reconnect shows the current screen
33 rather than a blank one. anvil needs no fan-out channel of its own.
344. Multiple windows (agent / shell / `git log`) without more containers.
35
36(2) and (3) are the two hard problems in a web terminal, and tmux solves both.
37
38## The shared runner image
39
40`docker/runner/Dockerfile` builds **one** image used by both CI jobs and agent
41sessions, so a session can reproduce a build by hand and there is one thing to
42keep current. It carries tmux, git, fish, ripgrep and Claude Code (installed
43from Anthropic's signed apt repository, with the release key's fingerprint
44pinned in the Dockerfile).
45
46```
47./docker/runner/build.sh # anvil-runner:latest and :rust
48./docker/runner/build.sh latest # just the slim one
49```
50
51Two tags from one recipe on one base, differing only in whether the Rust
52toolchain is installed:
53
54| Tag | Base | For |
55| -------------------- | ------------------------ | ------------------------------- |
56| `anvil-runner:latest`| `ubuntu:26.04` | the default, general work |
57| `anvil-runner:rust` | `ubuntu:26.04` + rustup | pipelines needing the toolchain |
58
59The version rustup installs is not written here or in the Dockerfile: the
60build script reads `[toolchain] channel` from the repo's `rust-toolchain.toml`,
61so a job compiles with the same rustc a checkout does. Bumping Rust is that one
62file (plus the `rust-version` floor in `Cargo.toml`, which the pre-commit hook
63checks agrees) and a rebuild of this tag.
64
65**There is no registry behind this image.** It is built straight into the
66Docker daemon's local store, so it must be built on whichever host owns the
67socket anvil talks to. Because of that, `anvil_ci::docker::ensure_image` treats
68a failed pull as non-fatal when the image is already present locally — an
69unconditional pull, which is what CI used to do, fails for exactly this image.
70
71A pipeline may still name any image it likes; `image` in `.anvil/ci.toml` is now
72simply optional, and omitting it selects `ci.default_image`. The default image
73is always permitted regardless of `ci.allowed_images`, since a pipeline that
74omits `image` never named anything for an operator to allow.
75
76## Lifecycle
77
78- **Start** (`POST /{owner}/{repo}/-/agent`, owner-only) creates the row, then
79 creates, seeds and starts the container. The workspace is the commit's files
80 uploaded as a tar, exactly as CI seeds a job — no `.git` yet, so no push
81 credential exists to leak.
82- **Credentials** are uploaded the same way: `agent.credentials_dir` on the host
83 (a `~/.claude`) is tarred into the container's home. Never bind-mounted, so
84 the no-mounts invariant in the threat model still holds. Note the corollary —
85 a token the CLI refreshes *inside* the container is refreshed on a copy, and
86 is lost when the session ends.
87- **Attach** (`GET …/-/agent/{id}/ws`) opens a `docker exec … tmux attach` per
88 browser and bridges it to the websocket.
89- **Autonomous** sessions are the same interactive agent with the opening prompt
90 typed at it via `tmux send-keys`, rather than a separate headless mode. A
91 human can take over mid-run just by attaching — there is nothing to bail out
92 of.
93- **End**: the container exits when its tmux session does, and the entrypoint
94 propagates the agent's exit code, so `docker wait` is a valid completion
95 signal. The sweep also ends sessions past `agent.idle_timeout_secs` (with no
96 viewer attached *and* no output) or `agent.max_lifetime_secs`.
97- **Restart**: containers outlive anvil, and the live-session registry is
98 in-memory. Startup therefore reaps every container labelled `anvil.session`
99 and marks every row that still claims to be live. A terminal session cannot
100 be resumed the way `ci::requeue_interrupted` re-queues a job, so it ends.
101
102## The websocket protocol
103
104wterm ships a `WebSocketTransport`, and anvil deliberately does not use it: it
105is a raw byte pass-through with no control channel — no way to carry a resize —
106and a blind reconnect that would reattach without a repaint. The protocol here
107is a few lines instead:
108
109| Frame | Direction | Meaning |
110| ------ | --------- | ---------------------------------------------- |
111| binary | both | raw terminal bytes |
112| text | browser→ | `{"t":"resize","cols":N,"rows":M}` → `resize_exec` |
113| text | →browser | `{"t":"exit","code":N}` / `{"t":"error","message":…}` |
114
115## The terminal
116
117[wterm](https://wterm.dev) (Apache-2.0), vendored as published ESM under
118`crates/anvil-web/assets/wterm/` and embedded in the binary exactly as htmx is.
119**No bundler**: the tree has a single bare specifier (`@wterm/core`), which the
120page resolves with a three-line import map. The built-in core's WASM is inlined
121as base64, so there is no second request and no `import.meta.url` resolution to
122get wrong.
123
124It is DOM-first, which is why it was chosen over xterm.js: real browser text
125selection and find work on an agent transcript. The built-in core handles the
126alternate screen buffer, mouse tracking, bracketed paste, synchronized output
127and OSC 8 links, which is the surface a Claude Code TUI uses. If a TUI ever
128glitches, `@wterm/ghostty` (libghostty's VT parser, ~400K) is a documented
129drop-in: `new WTerm(el, { core })`.
130
131The session page tears the socket and the WASM instance down on
132`htmx:beforeSwap`. The layout sets `hx-boost` on `<body>`, so without that
133teardown both would leak on every navigation.
134
135## Configuration
136
137See the `[agent]` block in `anvil.example.toml`. The knobs that matter:
138`enabled`, `credentials_dir`, `max_concurrent` (each session holds a container
139open, so this is the real resource bound), `idle_timeout_secs` and
140`max_lifetime_secs`.
141
142`memory_mb` defaults to 4096 rather than CI's 2048 because Claude Code asks for
1434 GB. Unlike CI there is no network opt-out: a session cannot work without
144reaching the model API.
145
146## Not yet done
147
148- **A real checkout.** M1 seeds files only. Giving the container a clone with
149 history and a working remote needs a push credential, which does not exist
150 today (tokens are read-only, and Bearer is accepted only on GET/HEAD).
151- **Ref scoping for that credential.** Restricting a session to write only
152 `refs/heads/agent/*` needs a ref filter in receive-pack. Until it exists, any
153 push credential handed to a session could write `main`.
154- **Trigger surfaces** from a TODO item, an issue, or a red CI run.
155- **Rate limiting.** Nothing stops automated pushes from queueing sessions
156 once triggers land; `max_concurrent` bounds concurrency, not churn.