# marver
A TUI workspace for AI agent sessions — tmux orchestration, git worktree
management, and repo control in one place.
Start a task, describe it, and marver opens a tmux session with an agent already
working in its own worktree. Inspired by k9s, cmux, and Warp.
> **Status: early development.** Every subsystem exists and the loop closes, but
> nothing has been used in anger yet. Expect to be the first.
```
marver open the interface, starting a daemon if none is running
marver status report whether a daemon is running, and what it holds
marver restart stop the running daemon and start this version
marver upgrade install the newest published marver
marver cleanup remove the worktrees left by finished tasks
marver daemon run the scheduler and hook receiver in the foreground
marver scan [root] list the git repos it can see
marver --version print the version (-v, -V)
marver --help print usage (-h)
```
Options, on every command: `--data-dir` (database, socket, log), `--scan-root`
(where repos are looked for), `--cap` (how many agents run at once), `--harness`
(which agent to run). The interface and daemon find each other through those
paths, so a `--data-dir` given to one must be given to the other.
Just run `marver`. If no daemon is listening it starts one, detached, the way a
tmux client starts its server — and that daemon keeps running after you close
the interface. It owns the queue: a worktree per repo, a tmux session, an agent,
and a notification when a task finishes or gets stuck.
## Keys
Three rules hold everywhere:
- **`esc` leaves the screen.** On the task list that means quitting.
- **`ctrl-c` and `ctrl-q` also leave** — `ctrl-c` drops the text being typed if
a field is open. In the agent's terminal `ctrl-c` goes to the agent; `ctrl-q`
still leaves.
- **`x` discards, `c` commits.** Never the other way round.
- **`/` filters the list you are on**, on every screen that has one. Typing
narrows as you go, `↵` keeps it, `esc` backs out — the field first, then the
filter, then the screen.
No other chord does anything: a modified key is not a letter binding.
**The wheel scrolls what is tall on the screen** — the agent's output, a diff —
and moves the selection on a list. A diff stops at its last line, and the brief
(`y`) is closed by a key rather than a notch. marver asks the terminal for the
mouse to do all this, so **drag-to-select needs `shift` held** (`option` in some
terminals). Without it the terminal turned wheel notches into arrow keys, which
in the agent's pane went to the agent and recalled its last prompt.
### Task list
| `j` `k` · `↓` `↑` | move |
| `g` `G` · `Home` `End` | first, last |
| `↵` | open the agent's terminal |
| `/` | filter by title, repo, state, or prompt |
| `y` | what it was asked to do — any key closes it |
| `v` | review the diff |
| `n` | new task |
| `p` | pause it, or resume it |
| `f` | say its agent has finished |
| `t` | its todos — `tab` there reaches the global ones |
| `x` | cancel it; again once it has ended to archive it |
| `X` | remove a finished task's worktrees — twice if there is work to lose |
| `u` | put an archived task back |
| `s` | cycle order — newest, oldest, by repo |
| `a` | cycle what is shown — open, all, archived |
| `r` | refresh |
| `R` | the repos |
| `esc` `q` | quit |
### New task
| `tab` | next field: repos → prompt → title |
| `/` | filter the repo list |
| `space` | select a repo |
| `j` `k` | move through repos |
| `↵` | queue it — from the repo list or the title |
| `ctrl-s` | queue it from anywhere, including inside the prompt |
| `esc` | discard |
`↵` types a newline in the prompt rather than queueing, since a prompt is
routinely several paragraphs. **The title is optional** — left blank it is the
first line of the prompt, cut at a word, shown dimmed before it is taken.
### Agent terminal
| `ctrl-q` | back to the list |
| `ctrl-esc` | back to the list, where the terminal can send it |
| wheel | back through the agent's output — 10,000 lines are kept |
| `↵` | to the agent, and returns a blocked or reviewed task to `running` |
Every other key goes to the agent, `esc` included — it is how Claude is
interrupted. Any key also brings the view back to what the agent is printing
now, so answering after scrolling up does not leave you reading history. `ctrl-esc` needs the kitty keyboard protocol and will not arrive in
Terminal.app or iTerm2. **`ctrl-q` always works.**
### Todos
| `a` | add one — `↵` keeps it, `esc` drops it |
| `/` | filter them |
| `↵` | use it: sends it to the agent, or starts a task from it |
| `space` | tick it off, leaving it in the list |
| `x` | delete it |
| `tab` | switch between this task's todos and the global ones |
| `esc` `q` | back |
A task's todo is typed into that agent's session. A global one opens a new task
with the text as its prompt, and is ticked off only once the task is queued.
### Repos
| `j` `k` · `↓` `↑` | move |
| `g` `G` · `Home` `End` | first, last |
| `/` | filter by name or branch |
| `f` | fetch it |
| `p` | fast-forward it |
| `r` | read them all again |
| `esc` `q` | back |
### Review
| `j` `k` · `↓` `↑` | move between files |
| `space` `s` | stage or unstage the file |
| `a` | stage everything |
| `tab` | switch between the staged and working-tree diff |
| `ctrl-d` `ctrl-u` · wheel | scroll the diff |
| `/` | filter the files |
| `c` | commit — type a message, `↵` to confirm |
| `x` | reject — type instructions to the agent, `↵` to send |
| `r` | refresh |
| `esc` `q` | back |
Rejecting types your instructions into the agent's session and returns the task
to `running`. Instructions containing control characters are refused: `\r` would
submit half a sentence, `\x03` would interrupt the turn being asked for.
## Filtering
`/` on any list narrows it as you type, k9s-style. `↵` keeps the filter and
gives the keys back, `esc` drops what you typed, `esc` again clears the filter,
and only then does it leave the screen.
It searches more than the row shows: a task matches on its title, repo, state,
*or prompt*, so `/auth` finds the task you described that way even when the
title says something else. The count in the header says what is hidden — `Tasks
(2 of 4 open) · /auth`.
Two details worth knowing. On the new-task screen only the repo list filters,
since in the prompt `/` is a character you are typing — and a repo you chose
stays chosen when the filter hides it. On the repo screen the filter narrows
what you see and not what is being fetched, so a job already running still
finishes for every repo.
## Agents
```
marver --harness claude Claude Code (the default)
marver --harness codex OpenAI's codex
marver --harness opencode:opencode anything else, by name and command
marver --harness 'aider:aider --yes' with arguments, if it needs them
```
The whole command is one `--harness` value, so quote it — anything after the
space is otherwise read as an option to marver, which does not have it. Quote
again inside for an argument that contains a space — `--harness 'x:prog --mode
"go fast"'` — and it stays one argument all the way to the agent.
Worktrees, a queue, a session, a diff to review: none of that is specific to
Claude Code. Being *told what the agent did* is, and it is what the states run
on. So a harness is defined by what it can say:
| `claude` | a settings file of hooks | started, finished, blocked, and why |
| `codex` | its `notify` program | finished a turn |
| anything else | nothing | nothing |
**A codex task never blocks** — `agent-turn-complete` is its only event — and
shows no token figures, which come from a transcript only Claude Code writes.
**Anything else is started and then watched**: everything works except a task
that finishes by itself. `f` on the task list is how you say it has.
Neither has its config edited. Claude Code takes `--settings` and codex takes
`--config notify=[…]`, so reporting is wired on that one task's command line —
marver never writes to `~/.claude` or `~/.codex`.
`f` earns its place on Claude Code too: a `Stop` arriving while the daemon is
down is simply gone, since `marver hook` always exits 0.
## The repos
`R` shows what every scanned repo is standing at — what a new task would start
from:
```
repo branch upstream default changes
api main up to date clean
marver release-prep ↑2 ↓1 ↓12 origin/main 3 files
notes detached 1 file
old-thing gone from disk
```
**upstream** is the gap to the branch this one tracks; **default** is the gap to
`origin/main`, which says whether starting here would branch from something
stale. The second is blank when it would only repeat the first. Behind is
coloured, ahead is not.
`f` is a plain `git fetch` (no `--prune`) and `p` is `git pull --ff-only`, both
deliberately timid: they run on one keystroke against a repo you are not looking
at and possibly one an agent is working in. Neither blocks the interface — rows
fill in as they arrive, one job at a time.
The list comes from the daemon's last scan, not a fresh walk, so it agrees with
the repo picker on the new-task screen.
## Tasks
**`y`** puts a task's brief over the row: repos, branch, when it was described
and when it *started* (two different times — the gap is the queue), and the
prompt in full. The same brief is written to `task.md` in the task's workspace
when it launches, so the directory says what it is for.
**`p`** sets a task aside and picks it up again. One that never started is held
out of the queue; one with a live agent is interrupted with `esc` and frees its
slot. Nothing confirms the interrupt — Claude reports what an agent does, not
what is done to it — so resuming types `continue` into the session rather than
only setting the state back.
**`x`** cancels a task that is still going. Pressing it again once the task has
ended kills its tmux session and archives the row; `a` cycles round to the
archive and `u` brings one back. Archived is a date beside the state it ended
in, not a state of its own.
**Tokens.** A task's screen shows `opus-5 · 310k ctx · 334k out` beside the repo.
`ctx` is a *level* — how full the window was on the last turn — and `out` is a
*total* across every turn. Nothing is shown until the agent has answered once,
because unknown is not zero. There is deliberately **no cost figure**: the
transcript records no prices, so one could only come from a hardcoded table.
## Cleaning up
A task keeps its worktrees after it ends — `git worktree remove --force`
discards uncommitted changes, and losing unreviewed work while tidying a list
would be worse than a directory left on disk.
```
marver cleanup remove the worktrees of finished tasks
marver cleanup --dry-run list what would go, change nothing
marver cleanup --branches delete their branches too, where merged
marver cleanup --force remove work that was never committed
```
Only `committed`, `failed`, and `cancelled` tasks are offered; a worktree still
holding changes is listed and left alone. Branches are kept by default, since
for a committed task that branch holds the only copy of the work. Nothing runs
this on a schedule.
## Upgrading
```
marver upgrade # cargo install marver --force
marver upgrade --restart # and swap the daemon too
```
Installing does not replace a running daemon: it keeps executing the old binary
and still holds the socket. That is deliberate — it is supervising live agents.
`marver status` says when the versions differ and `marver restart` swaps it,
refusing while any task is `running` or `blocked`. Agents in tmux survive a
restart either way.
## What it costs
Measured on an M-series Mac, marver 0.0.20, release build. Percentages are of one
core.
| daemon, idle | 0.03% | 7.4 MB |
| interface, task list | 0.01% | 8.2 MB |
| interface, agent's terminal, agent working | 1.8% | |
| interface, 9 MB/s of pane output | 31% | |
The daemon does nothing between events; the 60-second repo scan takes 70µs over
five repos. The interface draws only when something changed. The expensive case
is the VT parser, and nothing a real agent does approaches that rate.
## Known gaps
- **The whole loop has never been run end to end** by a person, though every
part is tested — 600-odd tests, several driving real tmux servers and repos.
- **A dismissed permission prompt does not unblock a task.** Claude Code emits
a hook when you *type* a reply, not when you answer a dialog, so a task
blocked on a permission prompt stays `blocked` until the agent finishes.
- **Nothing reclaims a worktree on a schedule.** `X` on the task list removes
the selected finished task's worktrees and `marver cleanup` sweeps them all,
but neither happens by itself.
- **Two data directories cannot share a repo.** Sessions carry a tag for the
data directory that made them, so two marvers on one machine no longer fight
over `marver-1` — but branches are still named `marver/<id>-<title>`, so two
of them starting the same-numbered task against the *same* repo collide on
the branch.
- **A hook arriving while the daemon is down is lost** — `marver hook` always
exits 0 by design. `f` on the task list is the way out.
- **An old daemon keeps serving after an upgrade**, by design. `marver restart`.
- **A codex task never blocks and shows no tokens**; an unknown harness reports
nothing at all.
- **A multi-repo commit is one commit per repo**, sharing a message. If a
later repo fails, the earlier ones are put back with `reset --soft`, so the
task commits all or nothing — unless the undo itself fails, which it says.
- **Status messages last 500ms**, errors included.
- **Invalid option *values* are ignored silently**, except `--harness`.
- **Token figures depend on a format nobody promised to keep**, so they are
built to fail quietly.
Upgrading migrates the schema. An old daemon keeps working against a migrated
database, but restart it when convenient.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the design and the reasoning behind
it.
## Requirements
`tmux` 3.x, `git`, and an agent — Claude Code, codex, or your own.
Building it needs Rust 1.88 or newer, for let-chains and for what ratatui asks
for. An older toolchain fails partway through the dependency graph rather than
at the manifest, so `rustup update` is the fix.
## Name
A *marver* is the polished steel table a glassblower rolls hot glass across,
shaping the piece between trips to the furnace. It is the surface where work
takes form.
## License
MIT OR Apache-2.0, at your option.