# marver — Architecture
**Status:** draft · **Last updated:** 2026-08-04
This document records the architecture agreed before implementation started.
Where a decision is still open it says so explicitly rather than guessing.
**Built so far:** components 1 (store), 2 (repo scanner), 3 (worktree manager),
4 (tmux driver), 5 (terminal), 6 (hook receiver), 7 (scheduler), 8 (notifier),
9 (diff/commit), and 10 (TUI shell), plus a `git` CLI wrapper, the **launcher**
that composes worktrees, tmux, and hooks into one operation, and the **daemon**
that runs them. Every component in §5 now exists.
**The schema is now append-only.** `marver daemon` opens a real database, so
amending `0001_initial.sql` is no longer safe; further changes need a new
migration.
---
## 1. What marver is
A TUI workspace for running AI coding agents. You describe a task; marver
creates git worktrees for the repos it touches, opens a tmux session in a
directory containing them, and starts Claude Code there. Agents keep running
after you close the TUI. When one finishes or gets stuck, marver tells you, and
you review the diff and commit from inside marver.
Inspired by k9s (navigable TUI over live state), cmux (agents in a terminal
multiplexer), and Warp (the terminal as an application).
### Scope
In scope for the first version:
- Task creation, queueing, and lifecycle tracking
- Agents other than Claude Code, to the extent that they can say what they are
doing — see the harness component in §5
- Worktree and repo management
- tmux session orchestration
- A fully interactive embedded terminal
- Diff review, staging, and committing
- Desktop notifications
Explicitly out for now:
- Pushing, pull requests, CI status, merging
- Creating tasks from GitHub issues
- Third-party plugins
---
## 2. Domain model
```
Repo A git repository, discovered by scanning ~/workspace for .git
Task The unit of work. One agent, one session, one workspace dir,
N repos (usually 1)
Worktree One per (task, repo), created and destroyed with the task
Session A tmux session; cwd is the task's workspace dir; holds Claude
Event Append-only log of hook deliveries and state transitions
```
A task owns exactly one agent and one session. What varies is how many repos it
targets. Selecting several repos produces one worktree per repo, placed side by
side, with the agent's working directory set to their common parent — so the
agent sees all of them at once.
### On-disk layout
```
~/…/marver/tasks/<task-id>/
├── repo-a/ ← worktree
├── repo-b/ ← worktree
└── ← tmux session cwd
```
Worktrees are organised by **task**, not by repo. A task's directory is created
when the task starts and removed when it is torn down.
A task records which repos it targets when it is *created*, but owns no worktrees
until it is *launched*. Provisioning at creation would make a long queue hold
disk and branch names for work that has not begun, so `task_repos` carries the
selection immediately and its worktree columns are filled in later.
---
## 3. Process architecture
```
TUI (marver) marverd (daemon)
├─ ratatui views ├─ owns SQLite
├─ VT emulation ──────┐ ├─ scheduler / auto-queue
└─ socket client ─────┼──socket──▶├─ notifications
│ └─ hook receiver ◀── claude hooks
│
└──control mode──▶ tmux server ──▶ claude
```
Three independent lifetimes:
- **tmux** outlives everything. Agents survive the daemon and the TUI.
- **marverd** outlives the TUI. It receives hooks and acts on them at 3am.
- **The TUI** is disposable. It holds nothing it cannot re-fetch.
State lives in exactly one place: the daemon's SQLite database. The TUI is a
client. This is what makes "close the laptop, come back tomorrow" work.
**The TUI opens the database directly rather than asking the daemon over a
socket**, which departs from the diagram above. SQLite in WAL mode supports
concurrent readers alongside one writer and `busy_timeout` covers contention, so
two local processes on one file is a supported arrangement rather than a
workaround. A protocol becomes necessary the moment the two stop sharing a
filesystem — viewing tasks from another machine is the obvious case — and until
then it would be a lot of machinery to reach a file that is already there. Only
the hook path uses the socket.
The daemon ships as `marver daemon`, not a separate `marverd` executable. It
generates hook settings invoking `marver hook` and finds that path via
`current_exe`; with two binaries it would have to guess where its sibling was
installed, and guess wrong whenever only one was on `PATH`.
**The interface starts the daemon it needs.** `marver` with nothing listening
spawns one, detached, and waits for its socket before opening the screen — a
client starting its own server, which is what tmux does and what makes `marver`
the only command anyone has to type. `marver daemon` remains, for watching it in
the foreground or running it under a supervisor.
Three properties this rests on:
- **The child gets its own process group.** Terminal-generated signals go to the
foreground process group, so a daemon sharing the interface's group would take
`ctrl-c` along with it, killing the supervisor of every running agent — the one
guarantee marver makes. Its stdio goes to `daemon.log`, since it has to outlive
the terminal it was started from.
- **Liveness is a connection, not a pid file.** The socket *file* outlives the
process that made it, so its presence proves nothing. A pid file answers a
different question — whether some process holds that number — which stops being
the same question the moment the number is reused.
- **The race resolves itself.** Two interfaces starting at once both spawn, and
the socket bind means exactly one survives. The loser is refused with "a marver
daemon is already running" rather than being allowed to displace the winner.
Startup reconciles before anything else. A task recorded as `running` whose tmux
session no longer exists — the machine restarted, or the agent died while the
daemon was down — is failed. Left alone it would hold a concurrency slot for
ever, and nothing would ever recreate its session.
The TUI talks to tmux directly over **control mode** (`tmux -CC`) rather than
screen-scraping — a line protocol that delivers `%output` notifications per pane
and accepts commands back. This is the same mechanism iTerm2 uses for its native
tmux integration. It means marver implements a terminal emulator and a renderer,
not a multiplexer.
Four properties of control mode, established against tmux 3.6b rather than from
documentation, because each one breaks a naive implementation:
- **It requires a TTY.** `tmux -CC attach` calls `tcgetattr` at startup and exits
if that fails, so the connection is spawned inside a pty, not a pipe. This is
why `portable-pty` is a dependency.
- **Command output inside `%begin`/`%end` is opaque.** `list-panes` returns lines
like `%0`. Dispatching on a leading `%` without tracking block state reads pane
ids as notifications.
- **`%output` payloads are octal-escaped** (`\033`, `\015`, `\134`), and carry
arbitrary bytes including invalid UTF-8, so they stay as `Vec<u8>`.
- **The `=` exact-match target prefix is session-only.** `send-keys -t =name`
fails with `can't find pane`, and `display-message -p -t =name` returns an
empty string with a zero exit status — a silent wrong answer rather than an
error.
---
## 4. Task lifecycle
```
queued ──▶ running ⟷ blocked ──▶ awaiting-review ──▶ committed
▲ │ │
│ └───────────────┤
└────────── reject ────────┘
(same live session)
queued | running | blocked | paused ──▶ failed
```
**`paused` is entered from three states and leaves to two, and remembers
neither.** A task held before it launched has no tmux session; one paused
mid-work does. That single fact decides both what pausing has to do — nothing, or
interrupt an agent — and where resuming returns it to, so no `resume_state`
column exists to drift out of step with the sessions that actually exist.
It holds no concurrency slot, which is the point of pausing a working agent: the
slot is what you are trying to free. Nothing in the scheduler consults it, since
a paused task is simply not `queued`. It is also the one state `UserPromptSubmit`
does not move: the session is still there and can still be typed into, but taking
the slot back on a stray prompt would undo the only thing pausing is for.
The uncomfortable edge: **no hook confirms an interrupt.** Claude Code reports
what an agent does, not what is done to it, so this state is asserted rather than
observed — the only one that is. Two consequences are designed around it. The
interrupt is sent *before* the transition, so a session marver cannot reach
leaves the state untouched rather than claiming a pause that never arrived. And
resuming types a real prompt into the session, because an idle agent behind a
task marked `running` would never produce the `Stop` that ends it.
**Answering the agent is a transition**, by two routes. Claude Code emits
`UserPromptSubmit` when a reply is submitted, which is the reliable one: it
arrives whether the reply was typed in marver's pane, in a terminal attached to
the same tmux session, or anywhere else. Submitting in a task's pane also moves
`blocked` or `awaiting-review` back to `running` directly, which covers a reply
made while the daemon is down. Enter alone, since that is what submits in every
Claude Code prompt while the keys that move around inside one should not claim
the agent is working.
Both routes ask for the same move, so whichever lands second finds the task
already `running` and is discarded.
Without it a task sat in `blocked` while its agent worked, and — worse — sat in
`awaiting-review`, which holds no concurrency slot, so the scheduler could start
another agent on top of one that was already running. The closing `Stop` did not
rescue it: it asks for `awaiting-review`, which is where the task already was, so
it was recorded and discarded.
**`blocked` reaches `awaiting-review` directly**, and the edge is load-bearing.
A dismissed permission dialog is not a submitted prompt, so it emits nothing at
all — and the next thing marver hears from an agent blocked that way is the
`Stop` that means it finished. Requiring a visible return to `running` first stranded the task, and
since `blocked` occupies a concurrency slot, a handful of permission prompts
wedged the queue permanently. It caught the ordinary end of every turn too:
`idle_prompt` is emitted alongside `Stop` with no ordering between them.
| `queued` | Waiting for a concurrency slot | Task creation | |
| `running` | Agent working | Scheduler, or resume after reject | |
| `blocked` | Agent needs the user | Claude Code `Notification` hook; left via `UserPromptSubmit` | |
| `awaiting-review` | Agent finished; diff needs eyes | Claude Code `Stop` hook | |
| `committed` | Changes committed locally | User action in the TUI | ✓ |
| `failed` | Agent crashed, session died, or setup never completed | Supervision | ✓ |
| `cancelled` | Abandoned by the user | User action in the TUI | ✓ |
Three properties of the failure states were chosen rather than inherited:
- **`failed` is unreachable from `awaiting-review`.** The agent has already
finished by then, so there is nothing left to crash. Discarding a finished
result is a cancellation, not a failure.
- **`failed` is terminal — there is no retry edge.** The tmux session behind a
failed task is gone, so there is nothing to resume into. Retrying means
creating a new task, which keeps the schema flat and avoids modelling attempts.
- **A failed task must record why.** `failure_reason` is required in that state
and forbidden in every other, enforced by a `CHECK` constraint, mirroring how
`blocked_kind` behaves.
Two further properties worth stating plainly:
**State comes from hooks, not from parsing output.** Claude Code fires hooks on
lifecycle events; those reach the daemon over a Unix socket. This is the
difference between "probably idle" and "blocked on approving an edit to
`src/main.rs`". It couples marver to Claude Code, which is an accepted tradeoff.
marver subscribes to six events — `SessionStart`, `UserPromptSubmit`, `Stop`,
`StopFailure`, `Notification`, `SessionEnd` — and configures them in a settings
file written per task, passed with `claude --settings`. `SessionStart` is only
acted on for a task that has not started yet: it also fires on `/clear` and on a
compaction, and treating those as "work has begun" dragged finished tasks back
out of review. The task id is fixed in the hook's
argument vector rather than inferred from the payload's `cwd`, which changes as
an agent moves between worktrees, or `session_id`, which is not known until
Claude has already started.
The forwarding command always exits 0. A non-zero exit is an error on the
agent's critical path, and marver being unreachable must never interfere with the
agent it is only observing.
**Rejection resumes the same session.** The session never died, so declining a
diff means typing follow-up instructions into a live terminal and moving the
state back to `running`. There is no attempts table and no re-spawn path.
`awaiting-review` is first-class: it has its own queue, its own notification,
and it is where the user is expected to spend their time.
---
## 5. Components
In rough dependency order.
| 1 | **Store** | SQLite schema, migrations, append-only event log |
| 2 | **Repo scanner** | Walk `~/workspace`, detect git repos, cache results |
| 3 | **Worktree manager** | Create/destroy per (task, repo); branch naming |
| 4 | **tmux driver** | Control-mode client, session lifecycle, `%output` demux |
| 5 | **Terminal** | VT parser, ratatui widget, input forwarding, resize |
| 6 | **Hook receiver** | Socket endpoint; maps hook payloads to transitions |
| 7 | **Scheduler** | Concurrency cap; promotes `queued` → `running` |
| 8 | **Notifier** | OS notifications on `blocked`, `awaiting-review`, `failed` |
| 9 | **Diff / commit** | Review across a task's worktrees, staging, commit |
| — | **Review screen** | Files and diff side by side; stage, then commit or reject inline |
| — | **Repo screen** | Branch, drift from upstream and default, uncommitted count; fetch and `--ff-only` pull, off-thread |
| — | **Archive** | A date on a finished task, not a state; killing its session is what taking it off the list means |
| — | **Brief** | What a task was asked to do, gathered once: the `y` popup and the `task.md` written into its workspace |
| — | **Harness** | Which agent runs a task and how it reports: Claude Code's hooks, codex's `notify`, or nothing at all |
| 10 | **TUI shell** | `View` trait, navigation, keymap |
| — | **Launcher** | Composes 3, 4, and 6: provision → settings → session → agent |
The launcher is not in the original numbering because it is glue rather than a
subsystem, but it is what turns the libraries into something that runs. It
implements the scheduler's `Launch` trait, so `Scheduler::tick` starts real
agents.
The agent is the tmux session's own process, started with an argv, rather than a
command typed into a shell running inside it. This is a correctness boundary,
not a style choice: anything typed passes through the terminal's line discipline
and the shell's line editor first, both of which act on control bytes before any
quoting is parsed. A prompt is arbitrary text that may have been pasted, so no
amount of quoting makes typing it safe.
Every daemon tick begins by **reaping**: killing the tmux session of any task
that has reached a terminal state. Nothing else does — cancelling in the TUI
writes a row, and Claude Code keeps running after `Stop` — so without it a
finished task leaves a live agent behind for ever. Reaping stops at the session.
Worktrees are left alone because removing one discards uncommitted changes, and
losing unreviewed work as a side effect of a cancel would be worse than leaving
a directory on disk.
Difficulty is not evenly distributed: **5 ≫ 4 > 7 > 9 > the rest.**
### Extensibility
The TUI is built on a `View` trait — `render` / `handle_key` / `tick` — with the
app owning a tree of views. Configurable panes are deferred, but the seam exists
from the first commit so they can be added without reworking the core.
Extensibility is scoped to two tiers: **the author now** (implement the trait),
and **users later** (declare layouts and keybindings in config). Third-party
code plugins are out of scope — that tier demands a stable public API,
sandboxing, and versioning for an audience that does not exist yet.
---
## 6. Open questions
These land in the schema or the scheduler and should be settled before the
relevant component is written.
- ~~**Concurrency cap**~~ — **settled: one global cap.** Which states consume a
slot mattered more than the number: `running` and `blocked` do, and
`awaiting-review` does not. A blocked agent resumes the instant it is
answered, so freeing its slot would let answering two prompts put you over the
cap; a finished turn consumes nothing, and counting it would let unreviewed
work starve the queue. A task that cannot be launched is failed rather than
left queued, so a broken task cannot wedge the queue behind it.
- ~~**`blocked` sub-kinds**~~ — **settled: three kinds, all reported.** Claude
Code's `Notification` hook carries a `notification_type`, so none of them is
inferred: `permission_prompt` → permission-prompt, `idle_prompt` → silence,
`elicitation_dialog` and `agent_needs_input` → question. The type also decides
whether a notification blocks at all — `auth_success`, `elicitation_complete`,
`elicitation_response`, and `agent_completed` are progress reports, and
treating every notification as blocking would park a working task the moment
it refreshed credentials. Unrecognised types block, since over-notifying is
recoverable and an invisibly stuck agent is not.
- **Tasks from GitHub issues** — `gh` operations are in scope generally, but
whether a task can be seeded from an issue is undecided.
- ~~**What `paused` would mean**~~ — **settled: both, as one state.** It was put
as a choice between holding a queued task and interrupting a working one; the
answer was to do each where it applies and let the task's own tmux session say
which is which. One state rather than a state plus a scheduler flag, and one
key rather than two. The interrupt is unobserved, which is accepted and
designed around rather than hidden — see §4.
- ~~**Todos**~~ — **settled: one table, two scopes, different verbs.** A todo
with a task is a note for an agent that exists, and using it types it into that
session. One without is work not yet scoped, and using it opens a new task with
the text as the prompt. Same shape, same screen, same keys; the scope decides
what `↵` means, which is the only thing a todo is for.
- ~~**Where cost and context come from**~~ — **settled: the transcript, for
everything except cost, which is not there.** The question assumed all three
were available at one price; a grep over a real transcript found `model` and
`usage` on every assistant line and **no currency field of any kind**. So
context and tokens are read from `transcript_path`, and cost is not shown —
marver would have to hardcode a price table to produce one, and be confidently
wrong the day prices moved.
The coupling is accepted on the condition that it can fail silently: every
field optional, unparseable lines skipped, failures never touching the
transition the hook carried. Reads are incremental by byte offset, so a
megabyte transcript costs each hook only what arrived since the last one.
- ~~**Reclaiming worktrees**~~ — **settled: always asked for, never automatic.**
`marver cleanup` offers terminal tasks only, checks each worktree with `git
status` and leaves the dirty ones alone, and keeps branches by default because
marver merges them nowhere — for a committed task the branch is the only copy.
`--branches` deletes via `git branch -d`, so git decides what is safe rather
than marver. `X` on the task list is the same act on one task, and asks twice
when a worktree is dirty rather than skipping it — the person is looking at
the row, which `cleanup` sweeping a list is not. Nothing runs either on a
schedule: the reason reaping stopped at the session was that automatic removal
cannot know what is in a directory, and moving the same act later does not
change that.
- ~~**Branch base**~~ — **settled: the repo's default branch.** Resolved
locally, never by fetching: `refs/remotes/origin/HEAD` first, then the first
conventional name that exists, then whatever is checked out. Each repo in a
multi-repo task resolves its own default, so one task can branch from `main`
in one repo and `develop` in another. Branches are named
`marver/<task-id>-<slug>`, the same name across every repo in the task.
- **Pane layout model** — deferred entirely. When it lands, the question is
whether a layout belongs to a task or to the workspace.
---
## 7. Risks
**The embedded terminal is most of the project.** Full interactive VT emulation
— correct cursor handling, colours, redraws, resize propagation, input
forwarding, scrollback — is where schedules go. `vt100` plus `tui-term` covers a
great deal of it, but escape-sequence edge cases are a permanent tax rather than
a one-time cost. Everything else on the component list is a known quantity.
The first cut came in far smaller than feared, because `vt100` owns the hard
part. What is *not* free is the seam either side of it: key encoding is a table
this codebase must own outright, and dimension order is `(cols, rows)` here and
`(rows, cols)` in `vt100`, which silently transposes the screen when confused.
**Worktree management is being built from scratch.** This is a deliberate
greenfield decision. It should carry a real estimate rather than be treated as a
footnote; correct worktree lifecycle handling, especially teardown and orphan
recovery, is more subtle than it first appears.
**The daemon is a support surface.** Startup, supervision, upgrades, stale
sockets, and "is it running?" become permanent concerns the moment state moves
out of the TUI. Accepted in exchange for notifications and auto-queuing, neither
of which is possible without a process that outlives the UI.
---
## 8. Decision log
| Marver owns state; tmux is substrate | A task board needs states tmux cannot express — blocked, awaiting review, queued behind others |
| Fully interactive embedded terminal | Chosen deliberately over snapshot or read-only views; marver should feel like the terminal, not a window onto one |
| tmux control mode as transport | Purpose-built for embedding; avoids screen-scraping |
| Claude Code hooks for state | Structured transitions beat pattern-matching output; worth the coupling |
| Daemon, not TUI-only | Required for notifications and auto-queuing, which must work while the UI is closed |
| The interface starts the daemon | Having to run two commands in two terminals was the first thing anyone met; tmux sets the precedent, and the socket bind makes the race safe |
| Foreground `marver daemon` kept | Self-daemonizing would mean a pid file that lies; supervisors want a foreground process, and the auto-start path already covers the common case |
| SQLite as the single store | One writer, WAL, no server; the daemon owns it |
| `View` trait now, config later, no plugins | Keeps the seam open without building a plugin runtime for an audience of one |
| Scan `~/workspace` for repos | Zero maintenance versus explicit registration, which rots |
| Task ends at local commit | Push, PRs, and CI would add network dependency and polling to the daemon; deferred |
| Greenfield | Deliberate; no dependency on existing tooling |
| `failed` and `cancelled` added | Without them a crashed agent sits in `running` forever and holds a scheduler slot permanently |
| Neither failure state is retryable | Retry means a new task; avoids an attempts table, consistent with rejection resuming in place |
| Worktrees are reclaimed by a command, not a timer | Removal cannot tell finished from abandoned-mid-edit; the person can |
| marver captures the mouse | The terminal was translating wheel notches into arrow keys, which the agent's pane forwards to the agent — scrolling to read output recalled its previous prompt instead. The cost is that drag-to-select needs shift held |
| A session is killed only if its cwd is inside the task's workspace | A tmux server is machine-wide, so a name is not proof of ownership; the first cleanup run under test killed a live session belonging to real work |
| A session name carries a tag for the data directory that made it | Sessions were named `marver-<id>` from the task id alone, so a second data directory's task 1 could not launch at all — the name was already taken by the first |
| One door to a live agent (`agent`) | Rejecting, resuming, and sending a todo all type into a session; three copies of "which pane, and is it ours" would eventually disagree, and the failure is text in a stranger's prompt |
| Pausing is a state, not a scheduler flag | A flag would have to be consulted everywhere the queue is read; a state that is not `queued` is skipped by construction |
| Resuming types a prompt | Nothing hooks an interrupt, so an agent left idle behind a `running` task would wait for a `Stop` that is never coming |
| Migrations disable foreign keys around the batch | Rebuilding a table to widen a CHECK means dropping it while children reference it — with enforcement on that is a cascading delete, not a schema change |
| Tokens are reported, cost is not | The transcript records no price; a currency figure could only come from a hardcoded table, wrong the day prices move |
| Usage is read incrementally, and may fail | An undocumented format on the hook path must never cost a transition; unknown is a first-class answer |
| A multi-repo commit is all or nothing | Git cannot commit across repositories in one go, and half a task committed is a state with no name and no key to get out of it; `git reset --soft` puts the earlier repos back exactly where they were |