collin/anvil
RenderedSource
Agent sessions
An agent session is a long-lived container running tmux plus an agent CLI (Claude Code) against one repository, attached from a terminal in the browser.
It is off by default. Turn it on with agent.enabled in anvil.toml, and read
untrusted-mode.md §7 first — a session is a genuinely
different exposure from CI.
browser (wterm) <--websocket--> anvil-web
|
docker exec (tty)
v
supervisor ---- tail -F ----> [ container: tmux -> claude ]
| |
'--> transcript on disk pipe-pane
Why tmux
With a long-lived container you could just docker exec -it a shell. tmux earns
its place for four reasons, in order of importance:
- A dropped websocket must not kill the agent. An exec session is tied to its client. The agent is a process inside tmux rather than a child of the exec, so closing the tab detaches a client and nothing more. This is the decisive one.
tmux pipe-panegives a byte-exact transcript that keeps recording whether or not anyone is watching — the storage story for autonomous runs.- tmux is already the multiplexer. Several browsers attach to the same session and tmux repaints each one, so reconnect shows the current screen rather than a blank one. anvil needs no fan-out channel of its own.
- Multiple windows (agent / shell /
git log) without more containers.
(2) and (3) are the two hard problems in a web terminal, and tmux solves both.
The shared runner image
deploy/runner/Dockerfile builds one image used by both CI jobs and agent
sessions, so a session can reproduce a build by hand and there is one thing to
keep current. It carries tmux, git, fish, ripgrep and Claude Code (installed
from Anthropic's signed apt repository, with the release key's fingerprint
pinned in the Dockerfile).
./deploy/runner/build.sh # anvil-runner:latest and :rust
./deploy/runner/build.sh latest # just the slim one
Two tags from one recipe on one base, differing only in whether the Rust toolchain is installed:
| Tag | Base | For |
|---|---|---|
anvil-runner:latest | ubuntu:26.04 | the default, general work |
anvil-runner:rust | ubuntu:26.04 + rustup 1.98.0 | pipelines needing the toolchain |
There is no registry behind this image. It is built straight into the
Docker daemon's local store, so it must be built on whichever host owns the
socket anvil talks to. Because of that, anvil_ci::docker::ensure_image treats
a failed pull as non-fatal when the image is already present locally — an
unconditional pull, which is what CI used to do, fails for exactly this image.
A pipeline may still name any image it likes; image: in .anvil/ci.yml is now
simply optional, and omitting it selects ci.default_image. The default image
is always permitted regardless of ci.allowed_images, since a pipeline that
omits image: never named anything for an operator to allow.
Lifecycle
- Start (
POST /{owner}/{repo}/-/agent, owner-only) creates the row, then creates, seeds and starts the container. The workspace is the commit's files uploaded as a tar, exactly as CI seeds a job — no.gityet, so no push credential exists to leak. - Credentials are uploaded the same way:
agent.credentials_diron the host (a~/.claude) is tarred into the container's home. Never bind-mounted, so the no-mounts invariant in the threat model still holds. Note the corollary — a token the CLI refreshes inside the container is refreshed on a copy, and is lost when the session ends. - Attach (
GET …/-/agent/{id}/ws) opens adocker exec … tmux attachper browser and bridges it to the websocket. - Autonomous sessions are the same interactive agent with the opening prompt
typed at it via
tmux send-keys, rather than a separate headless mode. A human can take over mid-run just by attaching — there is nothing to bail out of. - End: the container exits when its tmux session does, and the entrypoint
propagates the agent's exit code, so
docker waitis a valid completion signal. The sweep also ends sessions pastagent.idle_timeout_secs(with no viewer attached and no output) oragent.max_lifetime_secs. - Restart: containers outlive anvil, and the live-session registry is
in-memory. Startup therefore reaps every container labelled
anvil.sessionand marks every row that still claims to be live. A terminal session cannot be resumed the wayci::requeue_interruptedre-queues a job, so it ends.
The websocket protocol
wterm ships a WebSocketTransport, and anvil deliberately does not use it: it
is a raw byte pass-through with no control channel — no way to carry a resize —
and a blind reconnect that would reattach without a repaint. The protocol here
is a few lines instead:
| Frame | Direction | Meaning |
|---|---|---|
| binary | both | raw terminal bytes |
| text | browser→ | {"t":"resize","cols":N,"rows":M} → resize_exec |
| text | →browser | {"t":"exit","code":N} / {"t":"error","message":…} |
The terminal
wterm (Apache-2.0), vendored as published ESM under
crates/anvil-web/assets/wterm/ and embedded in the binary exactly as htmx is.
No bundler: the tree has a single bare specifier (@wterm/core), which the
page resolves with a three-line import map. The built-in core's WASM is inlined
as base64, so there is no second request and no import.meta.url resolution to
get wrong.
It is DOM-first, which is why it was chosen over xterm.js: real browser text
selection and find work on an agent transcript. The built-in core handles the
alternate screen buffer, mouse tracking, bracketed paste, synchronized output
and OSC 8 links, which is the surface a Claude Code TUI uses. If a TUI ever
glitches, @wterm/ghostty (libghostty's VT parser, ~400K) is a documented
drop-in: new WTerm(el, { core }).
The session page tears the socket and the WASM instance down on
htmx:beforeSwap. The layout sets hx-boost on <body>, so without that
teardown both would leak on every navigation.
Configuration
See the [agent] block in anvil.example.toml. The knobs that matter:
enabled, credentials_dir, max_concurrent (each session holds a container
open, so this is the real resource bound), idle_timeout_secs and
max_lifetime_secs.
memory_mb defaults to 4096 rather than CI's 2048 because Claude Code asks for
4 GB. Unlike CI there is no network opt-out: a session cannot work without
reaching the model API.
Not yet done
- A real checkout. M1 seeds files only. Giving the container a clone with history and a working remote needs a push credential, which does not exist today (tokens are read-only, and Bearer is accepted only on GET/HEAD).
- Ref scoping for that credential. Restricting a session to write only
refs/heads/agent/*needs a ref filter in receive-pack. Until it exists, any push credential handed to a session could writemain. - Trigger surfaces from a TODO item, an issue, or a red CI run.
- Rate limiting. Nothing stops automated pushes from queueing sessions
once triggers land;
max_concurrentbounds concurrency, not churn.