anvilsign in

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:

  1. 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.
  2. tmux pipe-pane gives a byte-exact transcript that keeps recording whether or not anyone is watching — the storage story for autonomous runs.
  3. 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.
  4. 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, differing only in base:

TagBaseFor
anvil-runner:latestubuntu:26.04the default, general work
anvil-runner:rustrust:1.95-bookwormpipelines 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 .git yet, so no push credential exists to leak.
  • Credentials are uploaded the same way: agent.credentials_dir on 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 a docker exec … tmux attach per 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 wait is a valid completion signal. The sweep also ends sessions past agent.idle_timeout_secs (with no viewer attached and no output) or agent.max_lifetime_secs.
  • Restart: containers outlive anvil, and the live-session registry is in-memory. Startup therefore reaps every container labelled anvil.session and marks every row that still claims to be live. A terminal session cannot be resumed the way ci::requeue_interrupted re-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:

FrameDirectionMeaning
binarybothraw terminal bytes
textbrowser→{"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 write main.
  • 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_concurrent bounds concurrency, not churn.