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 — describe a task, an agent runs it, review the diff, commit — but nothing has been used in anger yet. The published crate is still a name reservation.
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)
Every command takes --data-dir for where the database, socket, and log live,
--scan-root for the directory searched for repos, and --cap for how many
agents may run at once. The interface and the 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 the server it needs — and that daemon keeps running after you
close the interface. It owns the queue: it creates a worktree per repo, opens a
tmux session, starts Claude Code with hooks pointing back at itself, and notifies
you when a task finishes or gets stuck. Its output goes to daemon.log beside
the database.
Run marver daemon yourself to watch it in the foreground or to put it under a
supervisor. Starting a second one is refused rather than allowed to displace the
first.
Keys
Two rules hold everywhere:
escleaves the screen you are on. On the task list there is nothing underneath, so leaving is quitting.xdiscards. A task on the list, a diff in review. It is neverc, which means commit.
The footer of every screen shows its own keys; the whole set is below.
Task list
| Key | |
|---|---|
j k · ↓ ↑ |
move |
g G · Home End |
first, last |
↵ |
open the agent's terminal |
v |
review the diff |
n |
new task |
p |
pause it, or resume it |
t |
its todos — tab there reaches the global ones |
x |
cancel the task |
s |
cycle order — newest, oldest, by repo |
a |
show finished tasks too |
r |
refresh now |
esc q |
quit |
New task
| Key | |
|---|---|
tab |
next field: title → prompt → repos |
space |
select a repo |
j k |
move through repos |
↵ |
queue it — from the title or the repo list |
ctrl-s |
queue it from anywhere, including inside the prompt |
esc |
discard |
↵ inserts a newline in the prompt rather than queueing. A prompt is what the
agent is told to do and is routinely several paragraphs, so the only way to type
a newline is worth more there than the shortcut; ctrl-s is how you submit
without leaving the field.
Agent terminal
Every key goes to the agent, which is the point of the screen, so leaving takes a chord.
| Key | |
|---|---|
ctrl-q |
back to the list |
ctrl-esc |
back to the list, where the terminal can say it — see below |
↵ |
sent to the agent, and returns a blocked or reviewed task to running |
esc is not a way out here: it is how Claude is interrupted, and the agent needs
it. ctrl-esc is the binding this screen would like to have, but a terminal can
only report it under the kitty keyboard protocol, which marver does not enable —
turning it on changes how every key is reported, and this screen forwards every
key through a hand-built encoding table. In Terminal.app and iTerm2 it will not
arrive at all. ctrl-q is the one that always works, in every terminal,
because raw mode clears IXON and so nothing upstream takes it for flow control.
Todos
| Key | |
|---|---|
a |
add one — ↵ to keep it, esc to drop it |
↵ |
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 |
Review
| Key | |
|---|---|
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 |
scroll the diff |
c |
commit — type a message, ↵ to confirm, esc to cancel |
x |
reject — type instructions to the agent, ↵ to send |
r |
refresh |
esc q |
back |
Rejecting types your instructions into the agent's live session and returns the
task to running. Instructions containing control characters are refused:
send-keys -l stops tmux reading them, but the agent still sees the bytes, and
\r would submit half a sentence while \x03 would interrupt the very turn
being asked for.
Upgrading
marver upgrade # cargo install marver --force
marver upgrade --restart # and swap the daemon too
marver upgrade drives cargo, because that is how the crate is distributed. If
you installed marver some other way it says so rather than guessing, and you
upgrade it however you installed it.
Installing a new marver does not replace a daemon that is already running: it keeps executing the old binary, and since it still holds the socket, nothing starts a replacement. This is deliberate — that daemon is supervising live agents, and restarting it underneath them would fail every task it was watching.
marver status reports both versions and says so when they differ, and
marver restart does the swap when you are ready:
marver restart
It refuses while any task is running or blocked, because that is the one
thing a restart can cost. marver hook always exits 0 — marver being down must
never interfere with an agent — so a hook arriving while nothing is listening is
simply lost, and the task is left running behind an agent that has already
finished. Wait for those tasks to reach awaiting-review, or accept it with
marver restart --force.
Nothing restarts the daemon on your behalf. It is supervising live agents, and the moment to interrupt that belongs to whoever knows what they are in the middle of. Agents already running in tmux survive a restart either way.
What an agent has spent
A task's screen shows what its agent is costing you, beside the repo and branch:
running api marver/4-fix-auth opus-5 · 310k ctx · 334k out
Three things, and the two numbers mean different things. ctx is a level —
how full the context window was on the most recent turn — and out is a
total, output tokens across every turn since the task started. Summing the
first or replacing the second would both be wrong, and both would look plausible.
Nothing is shown until the agent has answered once. Unknown is not zero: a row of zeroes would claim the agent had run and produced nothing.
There is no cost figure, deliberately. None of this is in the hook payloads — the only source is the transcript JSONL that Claude Code writes, and it records no price of any kind. A number in dollars could only come from marver multiplying tokens by a price table it hardcoded, which would be confidently wrong the day prices moved. Tokens are what the transcript knows, so tokens are what marver reports.
That transcript is an internal format with no compatibility promise, which is why this whole feature is built to fail quietly: every field is optional, unparseable lines are skipped, and a transcript that stops making sense costs a task its numbers and nothing else. Reading is incremental — each hook reads only the bytes added since the last one — so watching a long task does not get more expensive as it runs.
Pausing
p on the task list sets a task aside, and p again picks it back up. One key
in both directions, because from there you are deciding whether to look at
something now or later, not which state it is in.
What it does depends on where the task is, and you should know which:
- A task that has not started is simply held out of the queue. The scheduler passes over it and starts the next one instead. Resuming puts it back in the queue, subject to the cap like anything else.
- A task with a live agent —
runningorblocked— is interrupted. marver sends the agentesc, which is how Claude is stopped mid-turn, and frees the concurrency slot so something else can run. The tmux session stays alive, so nothing is lost.
Which one you get is decided by whether the task has a session, and where resuming returns it to is decided the same way. Nothing records where it came from, so nothing can be wrong about it.
Two things worth knowing about the second case. Nothing confirms the
interrupt — Claude Code's hooks report what an agent does, not what is done to
it — so pausing asserts a state rather than observing one. And because of that,
resuming types continue into the session rather than only setting the state
back: an agent sitting idle behind a task marver believes is running would
never produce the Stop that finishes it. It is a real prompt to a real agent,
not an internal signal.
Pausing happens before the state moves, so a session that cannot be reached leaves the task exactly where it was, and says so.
Todos
t opens the todos for the task under the cursor; tab there switches to the
global list. They are the same screen and the same keys, and they differ in what
↵ means — which is the only thing a todo is for:
- A task's todo is a note for an agent that already exists.
↵types it into that agent's session and ticks it off. If it could not be delivered it is left undone, since losing the note as well as the attempt would be worse. - A global todo is something you have not scoped yet.
↵opens the new-task screen with the text as the prompt and a title cut from it, leaving you to pick the repos. It is ticked off when the task is actually queued, not when the screen opens — a task you thought better of leaves the note outstanding.
space ticks one off and leaves it in the list; x deletes it. Two keys because
they mean different things, and a list that hid the finished ones would make the
first look like the second.
Cleaning up
A task keeps its worktrees after it ends. Cancelling kills the agent's session
and stops there, deliberately — git worktree remove --force discards
uncommitted changes, and losing unreviewed work as a side effect of pressing
cancel would be worse than a directory left on disk.
marver cleanup is where that gets undone, as a separate act:
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. Anything else is
in use: a running agent is working in its worktree, and an awaiting-review task
is what the review screen reads its diff from.
A worktree still holding changes is listed and left alone, because "finished" is a state marver recorded and not a promise about what is in the directory. It says which ones and what to type if you meant it.
Branches are kept by default. marver commits onto marver/<id>-<slug> and merges
it nowhere, so for a committed task that branch holds the only copy of the work.
--branches deletes with git branch -d, which refuses any branch whose commits
exist nowhere else — so the branch of a task that did nothing is tidied away and
the branch of one that did something survives, and cleanup says how many it kept.
--force --branches deletes those too.
Nothing runs this on a schedule.
See ARCHITECTURE.md for the design and the reasoning behind it.
Known gaps
Written down because finding them yourself is worse.
The whole loop has never been run end to end. Every part is tested — 380-odd tests, several driving real tmux servers, real git repos, and the real binary — and the library has been exercised from outside. But describe a task → agent works → review the diff → commit has not been completed in one pass by a person. Expect to be the first.
Nothing reclaims a worktree unless you ask. marver cleanup exists — see
Cleaning up — but there is no key for it inside the interface and
nothing runs it on a schedule, so a marver left alone accumulates directories and
branches until you type the command.
Task ids name tmux sessions, and a tmux server is machine-wide. Two data
directories that each hold a task 1 both want the session marver-1, so the
second one to launch is refused with "session already exists" and its task fails.
Cleanup no longer kills across that boundary — it checks the session's working
directory first — but launching is still first-come-first-served. One data
directory per machine is the supported arrangement.
A hook that arrives while the daemon is down is lost. marver hook always
exits 0, deliberately, so marver being unavailable never interferes with an
agent — which means a Stop landing in the gap is simply gone, and the task is
left running behind an agent that has already finished. Startup reconciliation
does not rescue it, because it only fails tasks whose tmux session has died and
that session is still there. This is why marver restart refuses while anything
is running or blocked.
An old daemon keeps serving after an upgrade. By design — it is supervising
live agents. marver status warns and marver restart swaps it, but nothing
forces the issue, and a future schema migration could have a new interface
migrating a database an old daemon still has open.
ctrl-esc does not arrive in most terminals. See Keys.
ctrl-q always does.
A multi-repo commit is not atomic. One message becomes one commit per repo,
and if the second fails the first still stands. The task then stays in
awaiting-review with part of its work committed.
Status messages last 500ms, errors included, which is not long to read one.
Invalid option values are ignored silently. Unknown options are refused,
but --cap notanumber falls back to the default without saying so.
list_events cannot reach events with no task. Scans log against no task id,
and the only query takes one — so those rows are written and never read back.
Nothing confirms that a pause reached the agent. Claude Code reports what an
agent does, not what is done to it, so paused is asserted rather than observed.
If the interrupt did not land, the agent finishes its turn and the Stop hook
arrives for a task marver has already moved — the transition is refused and the
task stays paused with work quietly completed behind it. Resuming and reading
the pane is the way out.
Upgrading past 0.0.16 changes the schema. The first new marver to open the
database migrates it, and an old daemon may still be running against it. It keeps
working — it never asks for paused tasks — but a hook arriving for one is an
error it cannot read. marver restart after upgrading.
Token figures depend on a format nobody promised to keep. marver reads Claude Code's transcript JSONL for the model and token counts — see What an agent has spent. If that file changes shape the numbers stop appearing. Nothing else depends on them, and no cost is shown at all, because the transcript records no price to show.
Requirements
tmux 3.x, git, and Claude Code.
Name
A marver is the polished steel table a glassblower rolls hot glass across — shaping the piece and controlling its temperature between trips to the furnace. It is the surface where work takes form.
License
MIT OR Apache-2.0, at your option.