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