marver 0.0.11

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
# 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
- 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
- Agents other than Claude Code
- 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)

any non-terminal state      ──▶ cancelled
queued | running | blocked  ──▶ failed
```

**Answering the agent is a transition.** Claude Code emits no hook when a prompt
is answered, so the keystroke that answers it is the only evidence marver ever
gets — and the interface had been throwing it away. Submitting in a task's pane
now moves `blocked` or `awaiting-review` back to `running`. 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.

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.
Claude Code emits no hook when the user answers a permission prompt, so the next
thing marver hears from a blocked agent is usually 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.

| State | Meaning | Entered by | Terminal |
|---|---|---|---|
| `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 | |
| `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 five events — `SessionStart`, `Stop`, `StopFailure`,
`Notification`, `SessionEnd` — and configures them in a settings file written per
task, passed with `claude --settings`. 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.

| # | Component | Responsibility |
|---|---|---|
| 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 |
| 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.
- ~~**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

| Decision | Rationale |
|---|---|
| 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 |