Command-line interface
Every tma subcommand, its flags, and its exit codes. Transcribed from the
binary’s own --help; run tma <command> --help for the same text at any time.
tmux-agents CLI: agent state monitor, picker, jump, and stamping for tmux.
Usage: tma [OPTIONS] [COMMAND]
Running tma with no subcommand opens the fuzzy picker. Rows arrive in
attention order, blocked → done → working → idle → unknown, longest-in-state
first within each rank (done is a finished agent nobody has reviewed yet: idle,
with the attention flag still set). Its rows carry a dimmed
branch label (after the time column) when a listed pane resolves a git branch;
the picker itself stays a flat list, ungrouped. The pane you opened the picker
from is left out of the list (jumping to where you already are does nothing), so
opening it from your only agent shows an empty list; ls, status, and watch
still list every agent. Enter jumps to the highlighted
agent; tab opens that agent’s action menu instead of jumping. Every
printable key belongs to the query — no letter or digit is reserved for a
shortcut, so an agent named auth is searchable from an empty prompt. A popup at
least 76 columns wide carries
a live preview of the highlighted pane beside the list, the same threshold tma watch uses; below it the list takes the whole popup and nothing is captured. The
key tables list both
surfaces in full.
Global options
These are accepted before or after any subcommand and are read from one
canonical field, so tma --socket-name X ls and tma ls --socket-name X target
the same server.
Whichever way you name the server, tma forwards the same flag to every child it
spawns — the daemon it launches, a detached action’s supervisor, the tma act
entries in a display-menu — so nothing it starts lands on a different server
than you did. For the socket flags an explicit flag always wins over
TMA_SOCKET_PATH, which is consulted only when neither was given (the same
precedence --config has over TMA_CONFIG).
| option | value | meaning |
|---|---|---|
--manifest-dir | <DIR> | Load manifests only from this directory (test isolation). |
--socket-name | <NAME> | Target a specific tmux server socket by name (tmux -L <name>). |
--socket-path | <PATH> | Target a tmux server by socket path (tmux -S <path>), the form tmate and a hand-placed socket need (env TMA_SOCKET_PATH). Mutually exclusive with --socket-name: passing both is a usage error (exit 2). |
--config | <PATH> | Load config from this path instead of ~/.config/tma/config.toml (env TMA_CONFIG). An absent file is the zero-config floor (all defaults). |
-c, --client | <NAME> | The invoking tmux client for the picker/jump/watch Enter-jump. The run-shell jump bindings pass --client "#{client_name}" so the correct client is switched; absent, empty, or a still-unexpanded format (a binding context that does not expand, such as display-popup) falls back to targetless best-effort. |
--debug-timing | Print cycle timing and producer/consumer/capture counts to stderr, including capture-skipped (panes that reused their stamp because their window produced no output since it). Only the poll surfaces (ls/status/jump) act on it. | |
-h, --help | Print help. | |
-V, --version | Print version. |
The tmux binary itself is not a flag: it comes from TMA_TMUX_BIN, then
[tmux] bin in config, then plain tmux. That is what points tma at a tmate
socket or a second tmux build, whose servers refuse a mismatched client; see
[tmux].
Selector flags
ls, status, jump, wait, act, subscribe, and watch share one
vocabulary for saying which agents they are about. The flags are per-command
(they sit after the subcommand), and they mean the same thing everywhere.
| option | value | meaning |
|---|---|---|
--session | <NAME> | Only agents in this tmux session. |
--repo | <NAME> | Only agents whose pane resolves to this git repo. |
--branch | <NAME> | Only agents on this branch (the literal HEAD when detached). |
--agent | <NAME> | Only agents with this manifest name (e.g. claude). |
--state | <STATES> | Only agents in one of these states, comma-separated. |
Matching is exact string equality — no globbing, no case folding. Different
flags AND together (--repo app --state blocked is blocked agents in app);
--state’s comma-separated tokens OR within themselves. No flags means every
agent, exactly as before.
--state takes the same tokens as wait --until: idle, working, blocked,
unknown, and the pseudo-state done (idle plus attention: finished with output
nobody has reviewed). done is the narrower half of idle — --state idle
matches a done pane too, --state done does not match a plain idle one. An
unknown token is a usage error (exit 2) naming the valid set.
--repo matches the repo label the surfaces render, which is the origin repo’s
name, so it selects a repo’s linked worktrees along with its main checkout;
--branch is what splits them apart. A pane whose cwd resolves to no git repo
matches neither flag (an unresolved repo is not a wildcard). Resolving those
labels costs one memoized git call per unique directory, so status runs it
only when --repo/--branch is actually present.
Filtering is display-only, and happens after the cycle. Every invocation
still runs the full poll cycle and stamps every agent pane on the server; the
selector narrows only what that invocation prints, counts, emits, jumps to, or
(for act) acts on. A #(tma status --session X) driver refreshes the panes in
your other sessions exactly as an unscoped one does.
Commands
| command | summary |
|---|---|
version | Print version and build information. |
ls | List agent panes, one tab-separated line each (--json for the versioned schema). |
status | Print the status-line one-liner: state counts with glyphs and #[fg=] styling. |
jump | Jump focus to an agent pane across sessions (--attention / --blocked / --next / --back / --home / --pane), or menu them (--menu). |
attach | Hand this terminal to the session holding a pane (--pane %5): select its window and pane, then replace this process with tmux attach-session. |
wait | Block until the target reaches one of --until’s states, then print the matched row(s). One pane, or a fleet (--all / --count). |
act | Fire a guarded action into an agent pane (--all for every pane in scope), or enumerate/menu the fireable ones (--list / --menu). |
receipts | Read the dispatch ledger act --slot writes: what a dispatch ended as, without dispatching to find out. |
serve | Answer one remote connection over stdio: NDJSON requests in, NDJSON responses and events out. Spawned by an ssh forced command, not typed. |
device | Pair, grant, revoke and list the devices tma serve will answer. The whole write side of the remote scope model. |
mute | Suppress notifications for the panes in scope, for --for <DURATION> or until --clear. |
subscribe | Stream the read path: one complete ls --json document per line, pushed when a daemon is present. |
transcript | Read what the agent in a pane has been writing, as normalized events, newest first, a bounded page at a time. |
watch | Persistent live dashboard for a pane, window, or terminal of its own. |
daemon | Run the event-hub daemon in the foreground; --ensure spawns it if absent then exits. |
reload | Signal the running daemon to hot-reload its config and manifests (SIGHUP). |
init | First-run setup: detect your installed agents and wire their hooks, install the keybindings, print the status-right line, then report with doctor. |
install-hooks | Install, uninstall, or verify the agent and tmux hook wiring. |
install-keys | Install, uninstall, or verify tma’s tmux keybindings. |
doctor | Diagnose each agent pane’s effective tier and why. |
completions | Print a shell completion script on stdout (tma completions zsh). |
debug | Manifest-authoring and inspection tools. |
event | Internal, unstable: bridge one agent hook event to a stamp. |
clear-attention | Internal: clear the attention flag on the pane named and, when the tmux hook that fired it says a departure happened, on the pane just left; then nudge any resident tma watch. Invoked by the auto-installed tmux focus hooks. Navigation is not the only clear — the poll cycle also drops the flag on a pane a client is displaying once that client has been typed into after the flag went up. |
supervise | Internal: the detached-action supervisor. Spawned by the act broker’s detach path to hold the single-flight lock for the child’s lifetime, kill it at the deadline, then clear the lock and fire the completion notification. Never user-invoked. |
event is invoked only through the tma-hook wrapper an agent’s config
references, never by hand. debug stamp is likewise internal and unstable.
tma event authenticates nothing and is not meant to; the security boundary is
your user account, spelled out in The security
model.
tma ls
List agent panes.
Usage: tma ls [OPTIONS]
| option | meaning |
|---|---|
--json | Emit JSON ("schema": 1) instead of tab-separated lines. |
--pane <ID> | List only this pane id (e.g. %5), the single-row form. |
| selector flags | Narrow the listed rows. |
--pane and the selector narrow the same way: no matching agent prints nothing
and exits 0. A filtered --json is the same document with a shorter agents
array, so a consumer parses it identically.
Rows come back in attention order, blocked → done → working → idle → unknown,
then by session:window.pane. done is idle with the attention flag still set,
a finished agent nobody has read. --json emits the same order in its agents
array.
Plain output is one tab-separated line per agent pane, in this column order:
pane, agent, state, detail, since, session:window.pane, title,
attention, muted, repo, branch, worktree. The attention column is 1
when the pane still carries @agent_attention (finished or blocked output
unreviewed), empty otherwise; the muted column is the same marker for a pane
whose tma mute window has not expired.
The last three are the git labels. repo is the origin repo’s name, so a linked
worktree reports the same name as its main checkout; branch is the current
branch (the literal HEAD when detached); worktree is a 1-or-empty marker,
set only for a linked worktree. All three are empty for a pane in no git
checkout, which is the one case they cannot be told apart from a repo whose
labels failed to resolve — use --json, where they are null, if you need the
distinction. The JSON schema is documented in Pane options and JSON
contracts.
tma status
Print the status-line one-liner: state counts with glyphs and tmux #[fg=]
styling. As #(tma status) in status-right it is the required ambient
driver: each status-interval run refreshes the stamped pane options and
renders the counts.
Refreshing is a write, and tmux redraws every attached client in full on any
option write, even an unchanged one. An idle agent costs nothing (the cycle
reuses its stamp and writes no option); a working agent costs one redraw per
cycle. The status string itself is cheaper and independent: tmux writes only the
status row, and only when the expanded string changes, so a static
status-right costs nothing and a %H:%M clock costs one row write per minute.
Show agents in your status
line has the
details and the tmux version note.
Usage: tma status [OPTIONS]
| option | meaning |
|---|---|
--format <FORMAT> | Output form: tmux (default), plain, json, or prom. |
| selector flags | Count only the agents in scope. |
Output is the fixed order blocked working done idle unknown, zero-count classes
omitted, empty when there are no agents. Glyphs and colors come from [status]
config.
The tmux form also wraps each class in #[range=user|tma:<class>]…#[norange],
which tmux honors on a #() job’s output and which is what makes the counts
clickable (Clickable status
segments). The
markers draw nothing and do nothing on their own; the other three formats carry
no markup at all.
The counts are over the selected rows, which is what makes a per-session status
line possible: #(tma status --session #{session_name}). See
Show agents in your status line
for the caveats before you wire one.
--format
One set of counts, four renderings. Every form runs the same cycle over the same
selected rows, so which one you poll never changes what gets stamped: an external
bar polling --format plain is as much an ambient driver as #(tma status) is.
| format | output |
|---|---|
tmux | The default status-line one-liner, glyphs with #[fg=] styling plus the clickable-range markers. No trailing newline. |
plain | The same glyphs and counts with the color codes dropped, for a bar that applies its own styling. No trailing newline. |
json | {"schema":1,"counts":{"working":N,"blocked":N,"idle":N,"unknown":N,"done":N}}, one line. |
prom | Prometheus text exposition, for a node_exporter textfile collector. |
plain honors the configured [status] glyphs; only the colors go away. Both
one-liners omit zero-count classes and print nothing at all when there are no
agents.
json is the opposite: every class is present even at zero, so a consumer never
branches on a missing key. done and idle are disjoint counts — an idle
pane with unreviewed output is counted under done and not under idle, so the
five always sum to the number of panes in scope. That is the split the rendered
line has always shown, and it is deliberately not the same as the done key on a
JSON row (see tma ls), which is a subset of state: "idle"
because the row keeps its stored token.
prom emits two gauge families, each with its own HELP/TYPE comments:
# HELP tma_agents Agent panes in each state class. The classes are disjoint: ...
# TYPE tma_agents gauge
tma_agents{state="working"} 2
tma_agents{state="blocked"} 1
tma_agents{state="idle"} 0
tma_agents{state="unknown"} 0
tma_agents{state="done"} 1
# HELP tma_agent_state_seconds Seconds the pane has held its current state ...
# TYPE tma_agent_state_seconds gauge
tma_agent_state_seconds{pane="%5",agent="claude",state="blocked"} 42.000
tma_agents carries all five classes even at zero, so a series never disappears
mid-scrape. tma_agent_state_seconds is one series per agent pane, from the row’s
since against now; a pane whose transition was never stamped reports 0. Its
state label uses the same disjoint classes, so summing the per-pane series by
state reproduces tma_agents exactly. The textfile-collector recipe is in
Drive an external bar.
tma jump
Jump focus to an agent pane across sessions. At most one direction flag is used;
--next is the default when none is given.
Usage: tma jump [OPTIONS]
| option | meaning |
|---|---|
--attention | Jump to the next agent that wants you: blocked first (longest-blocked first), then finished-unreviewed (idle with attention). Advances from the current pane and wraps. |
--blocked | Jump to the longest-blocked agent. |
--next | Jump to the next agent after the current pane (session, then window, then pane order). |
--back | Return one step along the trail (the previous jump’s origin). |
--home | Return to the oldest recorded origin (the bottom of the trail) and clear the trail. |
--pane <ID> | Jump to this pane id. Records the origin like any forward jump and clears the pane’s attention flag; ignores the selector (the target is already named). A pane with no agent on it is a note on stderr and exit 0. |
--menu | Render a tmux display-menu of every agent (the pane you invoked it from excluded), each entry firing jump --pane on that agent. Needs an attached client; an empty list prints “no agents” and exits 0. |
| selector flags | Scope the candidates a forward jump may land on. |
The selector scopes triage: tma jump --attention --repo app walks only that
repo’s waiting agents, and reports “no agents waiting for you in scope” when it
finds none. --back/--home replay the return trail and ignore it.
A forward jump (--attention/--blocked/--next) pushes the current location
onto a per-client return trail. --attention and --blocked also clear the
destination pane’s attention flag (focusing a waiting agent reviews it); --next
is plain positional cycling and leaves attention untouched. --back pops one
entry; --home returns to the trail’s bottom entry and empties it. The trail is a
bounded stack (cap 8, oldest dropped past the cap) held in a per-client server
option, so --back/--home are independent per client. When the trail is empty
they print a note and exit 0.
Pass --client "#{client_name}" (a global option) from a run-shell binding,
which format-expands it, so the jump switches the client that pressed the key and
keys its return trail by it.
--menu is the tmux-native counterpart of the picker: entries are ordered like
the picker’s list (blocked, done, working, idle, then longest-in-state first), the first
nine carry a 1-9 quick-select digit, and each one runs tma jump --pane <id>
with the acting client and the invoking server resolved into the command. It is
what a right-click on a clickable status
segment opens.
tma attach
Hand the terminal you are sitting at to the tmux session a pane lives in. It
selects the pane’s window and pane on that session, then replaces this process
with tmux attach-session -t <session>, carrying --socket-name /
--socket-path through, so what comes back is a real tmux client showing the
pane you named.
Usage: tma attach --pane <ID> [--print]
| option | meaning |
|---|---|
--pane <ID> | The pane to land on (e.g. %5). The only target: the handover replaces this process, so there is nothing left afterwards for a selector to have narrowed. |
--print | Print the tmux attach-session argv instead of running it. The window and pane are still selected. Needs no terminal of its own, since it hands one over to nothing. |
This is the half jump cannot do. tma jump --pane is switch-client, which
moves a client that is already attached. From a terminal that has none (a
fresh ssh session, a phone’s terminal app) there is nothing for it to move.
Inside tmux, attach is jump. When $TMUX is set there is already a client
here, and replacing it with a nested one is never what anybody means, so the
command runs exactly tma jump --pane <ID>: same origin trail, same attention
clear, same “no agent in pane” note. --print then has no argv to show and says
so on stderr.
It refuses without a terminal. tma attach hands its own tty to tmux, so a
run whose stdin is not a terminal (a pipe, a run-shell, cron) exits 2 before
moving anything, rather than letting tmux fail with its own terse line after the
selects have already changed somebody’s focus. --print is exempt.
Exit codes:
| code | meaning |
|---|---|
0 | attached (the exec does not return), or --print printed the argv |
2 | stdin is not a terminal, so there is no tty to hand over |
3 | the pane vanished, whether before the selects or between them and the attach |
1 | a runtime failure (no tmux binary, an attach tmux refused) |
tma wait
Block until the target reaches one of --until’s states, then print the matched
row(s) and exit. It is the scripting primitive: a tier-2 poll loop (immediate
first cycle, then roughly one-second ticks with config and manifest hot-reload),
level-triggered, so an already-in-state target returns immediately. A transient
tmux stall is ridden out as a skipped tick (a one-time stderr note flags it), not
a failure; a vanished server still ends the wait.
Usage: tma wait [OPTIONS] --until <STATES>
One target, either an explicit flag or the selector’s --agent. The explicit
flags are mutually exclusive:
| option | meaning |
|---|---|
--pane <ID> | Wait on this specific tmux pane id (e.g. %5). Its disappearance while waiting is exit 3. |
--agent <NAME> | Wait on the agent pane with this name. Pins to the first in-scope pane observed, then behaves as --pane on it (a vanish is exit 3). Matching more than one in-scope pane at that first observation is an error suggesting --pane, never a silent first-match. |
--any | Wait on any agent pane in scope; the first to reach a target state (in surface-sort order) wins. --any never pins and keeps waiting on a vanish. |
--all | Barrier: succeed only when EVERY agent pane in scope is in a target state at once. |
--count <N> | Quorum: succeed once at least N agent panes in scope are in a target state. |
Naming no target at all is a usage error (exit 2). --agent is a selector flag
that doubles as a target, so it combines with the other four: --all --agent claude is a barrier over every Claude pane, and --any --agent claude is the
first Claude pane to land.
Membership. --all pins its membership at the first observation and
--count never pins. A barrier is over a fleet you already have: a pane that
launches mid-wait does not join it (and so cannot hold it open forever), while a
member whose pane dies ends the wait at exit 3, exactly as a --pane vanish
does. A quorum is over whoever shows up: it re-reads the scope every cycle, so a
pane appearing mid-wait counts toward N and one leaving is not an error. An
--all whose scope matches no pane at that first observation is exit 2 — a
barrier over an empty fleet would be vacuous success. --count stays permissive:
it waits for N matches among however many panes appear, so a scope that cannot
yet reach N simply blocks until --timeout.
Other options:
| option | meaning |
|---|---|
--until <STATES> | Required. The state(s) to wait for, comma-separated: idle, working, blocked, unknown, and done (idle plus attention, the finished-and-unreviewed surface). wait returns as soon as a cycle observes the target in any of them. done is by definition unreviewed: once you have the pane on screen and type at it, the mark comes down and does not come back for that episode, so a wait STARTED after that never satisfies. A wait already running is not robbed by its own poll — its cycle evaluates the goal before applying that clear — but wait on idle when a human is at the keyboard and may review the pane before your script gets there. |
--since <EPOCH_MS> | Only a state that BEGAN after this epoch-ms timestamp satisfies (the row’s episode_ms must be strictly greater). Works with every target. A done target also satisfies on a fresh completion of a pane that never left idle: a second turn end moves @agent_turn_at while since_ms stays pinned to the start of the idle run, and episode_ms is the later of the two. Feed the row’s own episode_ms back as the next floor, not since_ms: since_ms is a floor the row already clears, so the wait would re-satisfy on every lap. |
| selector flags | Scope --agent/--any/--all/--count. --agent is both the scope and the by-name target, so the flag that names an agent is the flag that selects it. Rejected alongside --pane, whose id is already unique (exit 2). |
--timeout <SECS> | Give up after this many seconds and exit 124 (the timeout(1) convention). Absent waits forever; compose with timeout(1) for an external belt. |
--json | Emit the matched row as one schema-1 JSON object (same keys as an ls --json row) instead of the tab-separated line. --all/--count emit the schema-1 agents document of the satisfied set instead. |
--since is the escape hatch from level-triggering. A supervisor loop that waits
for blocked, acts, and loops would otherwise re-satisfy immediately on the same
episode, because the state it waited for is still the current one; passing the
episode_ms of the row it just handled requires a NEW transition — or, for done,
a new turn end on a pane that has stayed idle throughout. The recipe is in
Block a script on agent
state.
--pane on a pane that exists but is not yet an agent blocks forever by design
(the agent may launch later); a one-time stderr hint flags a likely typo without
breaking scripts. Once a watched pane HAS been seen carrying an agent, the same
situation means the opposite thing: the agent process is gone while the pane
lives, so the wait ends at exit 4 naming the pane instead of blocking to a
timeout that could not tell a crashed agent from a slow one. That applies to
--pane, a pinned --agent, and --all members; --any and --count ignore a
departure and keep waiting for the others.
Exit codes
| code | meaning |
|---|---|
0 | A target state was observed (the row on stdout; --json for one schema-1 object, or the agents document under --all/--count). |
124 | Timed out (--timeout elapsed); nothing on stdout. |
3 | A watched pane vanished while waiting: a --pane, a pinned --agent, or an --all member. --any and --count keep waiting for the others. |
4 | The agent died while its pane lived on: a watched pane that HAD an agent row lost it. Same targets as 3; the message names the pane. |
2 | Usage error (bad --until token, no target named, an invalid target combination, or --all whose scope matched no pane). |
1 | A generic runtime failure (ambiguous --agent at first observation, or no tmux server). |
tma act
Fire a guarded action into an agent pane, or enumerate the fireable ones. One
verb, three modes: fire <name>, --list, or --menu. Actions are declared in
manifests (see Action manifest schema); the broker
re-verifies the target’s state, holds a single-flight pane lock, then acts. To
author one, see Author a custom action.
Usage: tma act [OPTIONS] [NAME]
[NAME] is the action to fire; omit it with --list / --menu.
| option | meaning |
|---|---|
--pane <ID> | Target this pane id (e.g. %5); defaults to the current pane inside tmux. Rejects the selector flags (exit 2), whose narrowing a pane id has already done. |
| selector flags | Scope the target. Alone they must resolve to exactly one pane: none is exit 3, more than one is exit 1 naming the candidates (--agent <NAME> is the common form). With --all the whole selection is the target set. |
--all | Fire on EVERY selector-matched pane, one after another. |
--dry-run | Print the resolved targets and each one’s gate verdict; execute nothing, acquire no lock. For a single target it also prints the resolved context (with each value’s age) and the would-be keys or command. |
--arg <VALUE> | Repeatable. Pass a value to an exec action’s command as environment (TMA_ARG, TMA_ARG_1..N, TMA_ARG_COUNT); never interpolated into the command string. Every other kind rejects it (exit 2). Under --all every target gets the same values. |
--text <STRING> | The string a text action delivers into the pane, literally and as one line. Required for a text action, rejected by every other kind (exit 2). The token after it is always taken as its value, so a message starting with - needs no quoting games. |
--force | Skip the when gate only, never requires and never the lock. |
--expect-episode-ms <MS> | Refuse (episode-changed, exit 4) unless the pane is still in this episode: the episode_ms of the tma ls --json row you acted on. Checked inside the action lock. A usage error alongside --all (exit 2). See Binding a dispatch to the pane you saw. |
--expect-permission-request <ID> | Refuse (request-gone, exit 4) unless the pane still carries this @agent_permission_request: the permission_request of that same row. Checked inside the same lock. A usage error alongside --all (exit 2). |
--slot <ID> | Dispatch at most once under this id. The first fire writes a receipt into the host ledger; a retry with the same id replays that receipt, exits with its code, and sends nothing. A usage error alongside --all (exit 2). See Retrying a dispatch safely. |
--device <NAME> | Record which device dispatched, on the slot’s receipt. Requires --slot and is never part of the slot’s identity, so another device’s retry still replays the first one’s receipt. A usage error alongside --all (exit 2). |
--yes | Satisfy a confirm action non-interactively (a non-TTY without --yes refuses). Under --all it covers the whole batch. |
--json | Emit schema-1 JSON: the fire result object (the results envelope under --all), or the --list document. |
--list | Enumerate actions; with --pane, include each one’s fireability verdict. |
--menu | Render a tmux display-menu of the currently-fireable actions (the keyboard-only parity surface, wired by tma install-keys). |
The --json result object, the --all envelope, and the --list document are
specified in
Pane options and JSON contracts.
A keys action can also carry a per-agent [hook] arm. On a claude pane whose
hook reply
lane is
holding a prompt open, approve and deny hand the verdict to that hook rather
than sending the key sequence: outcome replied, exit 0, kind hook in the audit
log. With no hook holding, the same fire sends the keys as it always did. --dry-run
names which of the two it would take.
Binding a dispatch to the pane you saw
A surface that reads a pane, shows a person the prompt, and dispatches their
answer some seconds later is answering a pane it can no longer see. If the agent
asked a second question in between, the approve meant for the first one lands on
the second. The --expect-* flags close that: a script reads episode_ms and
permission_request off the tma ls --json row it acted on and hands them back
on the fire.
episode=$(tma ls --json | jq -r '.agents[] | select(.pane == "%5") | .episode_ms')
# ... a person looks at the prompt and decides, some seconds later ...
tma act approve --pane %5 --expect-episode-ms "$episode"
Both are checked inside the pane’s single-flight action lock, against the same
read the gate is re-asserted from, so nothing can turn the prompt over between
the check and the keystrokes. A pane that has moved on refuses episode-changed;
one that no longer carries the quoted request id refuses request-gone. Both
exit 4 and send nothing. (request-gone on a vanished outcome is a different
event, exit 3: the API server’s own 404. The outcome field separates them.)
Two limits are worth knowing. The comparison is equality, not order, so a
backward wall-clock step can leave the pane at an earlier episode than the one
you read and that refuses episode-changed too, which is the honest answer: the
pane is not where you saw it. And a matching permission_request is a necessary
condition, not proof the prompt is still open, because tma clears the stamp only
when its own reply lands or the agent’s next event arrives; a matching id can
still name a request that has already been answered.
--dry-run reports the gate verdict and never fires, so it does not evaluate
either expectation.
Steering (--text)
tma act steer --pane %5 --text "use the existing helper instead of a new one"
tma act steer_now --pane %5 --text "stop and rebase onto main first"
steer sends one line to an idle agent; steer_now sends the same line to a
working one, where the agent queues it. They are separate actions rather than
one with a wider gate, because the second is only offered for agents that declared
they queue a mid-turn message rather than losing it. Neither is confirm = true,
so a script can drive them; neither is offered in the --menu, which has nowhere
to ask for the string.
The string reaches the pane exactly as typed. --text Enter types five characters
and presses nothing; --text C-c types three and interrupts nothing. Before any
tmux command runs, the host refuses a payload that is empty, over 4096 bytes,
carries a control byte (a steer is one line), or begins with one of the action’s
sigils (/ and ! by default), so a caller cannot reach /clear or /compact
through a message. Each refusal is exit 4 with its own reason token (empty,
too-long, control-bytes, sigil), and delivers nothing. The rules and the
manifest side of steering are in
Action manifest schema.
Retrying a dispatch safely
A caller that dispatches over a network cannot tell “the action never ran” from “the response never arrived”. A phone that suspends mid-request, or an ssh connection that drops, leaves the sender with no answer and exactly one bad option: send it again, and maybe approve twice.
--slot <ID> closes that. The caller invents an id for the dispatch it means to
make, and tma dispatches at most once per id:
tma act approve --pane %5 --slot "approve:%5:$episode" --device phone
# ... the response is lost; the phone reconnects and sends the same line ...
tma act approve --pane %5 --slot "approve:%5:$episode" --device phone
The second invocation prints the first one’s receipt, exits with the first one’s
code, and sends nothing. In --json it carries "cached": true (a fire that
actually happened carries "cached": false, so a slotted caller always finds the
key); in text it says cached receipt for slot ... on stderr.
The id is opaque to tma and it is the whole identity, so make it name the dispatch you mean: the action, the pane and the episode you read off the row, not just the action. Reuse an id for a different action and you get the first dispatch’s receipt back, which is what “at most once per id” means.
Three rules are worth knowing before you build on it.
- A
lockedrefusal (exit 5) releases the slot, because it is the one refusal that changed nothing and will pass on a retry. Every other outcome writes a terminal receipt,errorincluded: a broker failure cannot prove the keystroke did not land, so its receipt recordsfired-unknownand a retry replays it rather than sending a second time. - A receipt answers for 24 hours. Entries are evicted by size (the newest 4096 dispatches), never by age below that floor, because a short-lived receipt turns a late retry back into a genuine second fire.
- The ledger is one file per host,
0600under tma’s runtime directory, shared by every process on the machine. Two devices and two connections land on the same slot, which is what makes the retry idempotent rather than per-connection. A record that cannot be parsed refuses (exit 1) instead of guessing:tmanames the file so you can remove it.
--dry-run fires nothing, so it claims no slot.
Fan-out (--all)
--all resolves its targets from one cycle, then runs the ordinary per-pane
broker sequence on each in turn: every target takes its own single-flight lock
and re-verifies its own gate at fire time, so a fan-out is N independent fires,
never a shortcut around the guards. One target’s refusal does not abort the rest.
- A
confirmaction asks once for the batch, listing the panes, rather than once per pane;--yessatisfies the batch. --jsonemits theresultsenvelope, and it does so even when one pane matched, so a script’s parse does not depend on the match count.- The exit code is the WORST target’s, ranked: acted,
locked(5), a gate refusal (4),vanished(3),timeout(124), a failed exec child (its own code), a broker error (1). A fan-out exits0only if every target acted. - A selector that matches no pane is exit 2, not a silent no-op.
- Every fire in the batch shares one
batchid in the act audit log, so N lines of one fan-out are distinguishable from N separate invocations.
What --all is for, and what it is deliberately not. It is a fleet
convenience for the two actions you mean across a whole selection at once:
interrupt and deny. It is not a unified permission inbox, and tma will not
grow one. Batch approval is an exploited surface, not a hypothetical one: WorkOS
documented attackers embedding a dangerous operation inside a batch of benign
ones and using phrases like “don’t bother reviewing each one” to discourage
individual review (2026-08-05,
https://workos.com/blog/approval-fatigue-agent-governance), and the same
pattern is tracked as a threat rule. The local failure mode is smaller and just
as real: an action fires the keys its manifest declares, and one mis-typed
dialog turns an approve into something else, which is a bug tma has actually
shipped and fixed. Multiply that by every pane the selector matched. --all
stays because interrupting a fleet is a real need; approving one is a decision
you make one prompt at a time, which is what the picker and the action menu are
for. See The security model.
The act audit log
[act] log appends one JSON line per fired action, whatever the outcome:
[act]
log = "~/.local/state/tma/acts.jsonl"
Unset by default. The parent directory is created for you, ~ is expanded, and
the file is created 0600 and appended to, never rewritten, exactly like
notify.log. The natural place for
it is beside that file. A log that cannot be written is skipped silently: an
audit record must never turn a delivered action into a failure. Nothing rotates
it; that is logrotate’s job, or truncate’s.
The key set, in order:
| key | type | meaning |
|---|---|---|
schema | number | act-log schema version (1), versioned separately from the --json result |
at | number | epoch ms the fire completed |
pane | string | target pane id |
agent | string | null | @agent_name as read under the lock; null when the pane vanished before any read |
action | string | the action name |
kind | string | keys, api, hook, text, or exec: the transport the fire used, not just the manifest kind |
outcome | string | the --json outcome vocabulary |
reason | string | null | the refusal or vanish token, null for every other outcome |
source | string | which surface asked: cli (a person at a TTY), cli-yes (--yes, or no TTY to prompt on: a script, a hook, an agent), menu (the tmux action menu) |
episode_ms | number | null | the pane’s episode instant (max(@agent_since, @agent_turn_at)), read under the lock |
repeat | number | consecutive fires of this action on this pane in this episode, counting this one; 0 when the fire never reached the effect |
pending_tool | string | null | @agent_pending_tool: which tool the open prompt is about |
pending_call | string | null | @agent_pending_call: its call id |
all | boolean | whether the fire came from --all |
batch | string | null | the id shared by every fire of one --all invocation |
source is the field that makes the log worth keeping. Peers agree it is the
load-bearing one: Claude Code’s claude_code.tool_decision records a source
alongside the decision (config, hook, user_permanent, …) and Codex’s
codex.tool_decision records the configuration source the same way. A line that
says only “approved” cannot tell a human at a menu from a script from an agent
shelling out to tma act, which is precisely the question a tool with several
fire surfaces raises.
What the line never carries: no key, no token, no pane title, and no
agent-supplied prose. @agent_pending_summary is a command line or a path that
an agent chose, so the log names the pending call (pending_tool,
pending_call) and never quotes it. That is the same digest rule
notify.log follows, for the same
reason: this is the file most likely to be pasted into an issue.
jq -r 'select(.outcome=="sent") | "\(.at) \(.source) \(.action) \(.pane)"' \
~/.local/state/tma/acts.jsonl
The repeat warning
Three consecutive fires of the same action on one pane inside one episode print a
line to stderr and land as repeat: 3 in the audit log:
tma: 3 consecutive approve on %5 in this episode; the agent may be re-asking
It never refuses. The threshold is the vendors’: Claude Code’s auto mode pauses
when its classifier “blocks an action 3 times in a row”
(https://code.claude.com/docs/en/permission-modes) and Codex’s auto-review
circuit breaker aborts the turn at three consecutive denials. Both of those stop
an agent; tma is telling a person something instead, because the thing worth
noticing is that the same prompt keeps coming back, or that a finger keeps
answering it. A new episode, or a different action, starts the run over. The run
lives on the pane as @agent_act_repeat.
Exit codes
| code | meaning |
|---|---|
0 | Acted: keys or a text string delivered, an API-channel answer delivered (2xx), a hook-lane verdict written, a synchronous exec child exited 0, or a detached supervisor spawned. |
124 | A synchronous exec child was killed at timeout_ms. |
4 | The gate refused: state did not satisfy when, requires was unmet (including an API permission-reply op with no pending request id or no resolvable endpoint), the action does not apply to this agent, or the gated metric has no coverage. Also the two binder refusals, episode-changed and request-gone, when the fire carried an --expect-* the pane no longer satisfies (see Binding a dispatch to the pane you saw). The refusing fact goes to stderr. |
5 | The pane action lock is held by another invocation. |
3 | The act’s target disappeared mid-act: tmux reports the pane gone (can't find pane / no such pane), reason pane-gone; or the permission was answered or withdrawn between the gate and the act (an API 404, or a hook-lane verdict file that already exists), reason request-gone — the pane itself is still there. |
2 | Usage error (bad flag combination, selector flags alongside --pane, or --all whose selector matched no pane). |
1 | A runtime failure (no tmux server, a broker error, or an ambiguous selection without --all). A tmux command the server refused lands here, with tmux’s own stderr in the message — only a pane tmux reports as gone is exit 3. A --slot dispatch whose ledger cannot be read is also 1, and nothing is dispatched. |
Under --all the code is the worst target’s on the ladder above (see
Fan-out).
The reserved band (3, 4, 5, 2) is strictly pre-spawn broker verdicts. An
exec action that did spawn passes its child’s own exit code through verbatim, so
a child code can land inside that band; scripted consumers that branch beyond
success/failure read the --json outcome field, which is authoritative.
tma receipts
Read the dispatch ledger tma act --slot writes.
This is how a caller learns what a dispatch ended as when it lost the response,
without dispatching the action again to find out. It reads one local file: no
tmux, no pane lock, no keystroke.
Usage: tma receipts [OPTIONS]
| option | meaning |
|---|---|
--slot <ID> | Only the receipt for this slot id. |
--since-ms <MS> | Only dispatches claimed at or after this epoch-ms instant (inclusive). |
--json | Emit the schema-1 document instead of one line per receipt. |
Text output is one tab-separated line per receipt, oldest first, with - for an
absent field so the column count never varies:
at_ms slot pane action outcome reason exit_code device
--json emits {"schema":1,"receipts":[...]}, each element carrying slot,
pane, action, device, at_ms, outcome, exit_code and reason.
A dispatch still in flight has no receipt and is not listed. Entries stay until
the size cap evicts them, so a receipt older than the 24 h TTL can still be
listed even though a fresh dispatch on that slot would fire again; at_ms is
what says which. Exit 0 (an empty result is not an error), or 1 when the
ledger is torn.
tma serve
Answer one remote connection. NDJSON requests on stdin, NDJSON responses and events on stdout, logs on stderr; nothing but frames reaches stdout.
Usage: tma serve --stdio --device <ID>
| option | meaning |
|---|---|
--stdio | Speak the protocol over stdin and stdout. Required today. It is a flag rather than the implicit default so a future transport can be added without changing what a bare tma serve means. |
--device <ID> | Which paired device this connection belongs to, as tma device pair recorded it. |
This is spawned, not typed. One process per connection, started by an ssh forced command:
command="tma serve --stdio --device SHA256:0Mn3XQvC…",restrict ssh-ed25519 AAAAC3Nza… phone
sshd authenticates the caller and passes its id; serve trusts that argument and nothing in the stream. The handshake frame names a device too, and that field is the client’s own claim, used for the host’s log line and never for authorization. Serve tma over ssh is the recipe, and the remote wire protocol is what the two ends say to each other.
There is no listening socket, no TLS and no bearer token, because the ssh channel
is the transport. What a connection may do is read from the device store on every
request and every publish, so tma device revoke reaches a connection that is
already open: its next request is refused scope-denied, its event stream stops,
and the process exits.
Every serve connection runs its own detection cycle, so connections cost tmux
query throughput. [serve] max_connections caps them, four by default, and the
next dial is refused with a typed too-many-connections error rather than
accepted and starved. [serve] reconcile_interval_ms is both the stream’s poll
cadence and the freshness number the handshake quotes.
Exit 0 on EOF or SIGTERM, 2 when the connection is refused (an unknown or
revoked device, or the cap), 1 when the host could not start.
tma device
Pair, grant, revoke and list the remote devices tma serve will
answer. This is the whole write side of the scope model: grants are CLI-only.
No device can widen its own grants, no protocol frame asks for one, and there is
no in-app approval prompt. A device that wants more is told to ask the person at
the terminal.
Usage: tma device pair <NAME> --id <ID> [--scope <SCOPE>]... [--only]
tma device grant <NAME> <SCOPE>
tma device revoke <NAME>
tma device list [--json]
| option | meaning |
|---|---|
--id <ID> | The opaque id the spawner will pass as tma serve --device. For SSH that is the key’s fingerprint, which ssh-keygen -lf <key.pub> prints. |
--scope <SCOPE> | Grant this scope on top of the defaults, at pairing time. Repeatable. |
--only | On pair, grant read plus whatever --scope names, instead of the defaults. Bare, this is the watch-only device: it sees the fleet and can answer nothing. |
--json | On list, emit the schema-1 document instead of one line per device. |
The four scopes
| scope | grants | granted at pairing |
|---|---|---|
read | The fleet, transcripts, receipts, and cards rendered but inert. Implicit: every paired device holds it. | yes |
act:answer | approve, deny, question_reply, question_reject. | yes |
act:steer | steer, steer_now, interrupt, deny_with_message. | yes |
act:always | approve_always, whose affirmative answer grants every following action of its class. | no |
act:always is never granted by default and never by a device asking. It takes
tma device grant <name> act:always, typed on the host. In the other direction,
tma device pair <name> --id <id> --only grants read alone: a tablet you want
to watch with and never answer from.
A dispatch naming an action outside that table is refused, compact and any
action you wrote yourself included. The remote vocabulary is closed on purpose:
a phone reaching /compact is exactly the control-plane access the scopes exist
to withhold.
Pairing
$ ssh-keygen -lf ~/phone-key.pub
256 SHA256:0Mn3XQvC… phone (ED25519)
$ tma device pair phone --id SHA256:0Mn3XQvC…
paired SHA256:0Mn3XQvC… as phone
scopes: read, act:answer, act:steer
The record lands in ~/.config/tma/devices.toml, mode 0600, written by atomic
rename. Serve tma over ssh is the rest of the
recipe: the authorized_keys forced command that turns a dial into a connection.
Revocation
tma device revoke <name> removes the record. Every live serve process re-reads
the store per request and per publish, so a revoked device’s next request is
refused and its event stream stops and the process exits, without waiting for it
to hang up. Removing the authorized_keys line stops the next dial and nothing
else, which is why the record is the authority and the line is the courtesy;
revoke prints the line to remove.
Exit 0, 2 on usage, 3 when no device answers to that name, 1 when the
store could not be read or written.
tma mute
Stop a pane from notifying, without changing anything tma detects about it.
Usage: tma mute [OPTIONS]
| option | meaning |
|---|---|
--pane <ID> | Mute this pane id (e.g. %5); defaults to the current pane inside tmux. |
--for <DURATION> | Stay muted this long. Without it the mute holds until --clear. |
--clear | Lift the mute on the matched panes. |
| selector flags | Mute every pane in scope. |
The duration grammar is an integer plus an optional unit: s seconds, m
minutes, h hours, d days, with a bare number read as seconds (tma mute --for 90 is 90 seconds). Anything else is a usage error, as is 0 — a mute that is
over before it starts — and --for alongside --clear.
Targets resolve the way tma act’s do, minus the --all opt-in: a
selector mutes every pane it matches, because a mute is per-pane, idempotent, and
undone by one --clear. --pane and the selector flags are mutually exclusive.
What mute changes is the fire, nothing else. A muted pane is still detected,
still stamped, still counted by tma status, still blocked in tma ls and in
the JSON — it simply rings nothing: no display-message, no bell or OSC, no
[notify] command, for the state triggers and for context_high and stall. The episode’s
@agent_notified_at marker is written as usual, so a mute that expires mid-episode
does not then ring for a transition you already muted. A detached action’s
completion notification is deliberately outside the mute: you asked for that one,
and it reports once.
The deadline lives in the pane option @agent_mute_until (see
Pane options), which is what makes a mute survive a
tma restart, a daemon stop/start, and a config reload; --json rows carry the
resolved muted boolean.
| code | meaning |
|---|---|
0 | The option was written (or unset) on every target. |
3 | The selector matched no agent pane. |
2 | Usage error (bad --for value, --for with --clear, selector flags alongside --pane, or no target and not inside tmux). |
1 | A runtime failure (no tmux server, or a tmux command the server refused). |
tma subscribe
Stream the read path. One long-running process emits one complete ls --json
schema-1 document per line (the same document tma ls --json
prints), snapshot semantics with no diffs. It replaces a consumer’s own polling
timer: a Stream Deck plugin or dashboard spawns tma subscribe --json and
re-renders on each line, holding a connection to nothing but the tma binary.
The recipes are in Stream state changes.
Usage: tma subscribe [OPTIONS]
| option | meaning |
|---|---|
--json | Required. JSON is the only emission today; a missing --json is a usage error (exit 2). |
--interval <SECS> | Poll cadence when no daemon is present, and the degrade cadence when one dies (default 1). Push mode delivers on the daemon’s edge, so this only bounds the daemonless path. Must be at least 1. |
--changes-only | Skip a poll-mode emission that would repeat the last document. |
--events | Emit one edge record per state transition instead of snapshots. |
| selector flags | Emit only the agents in scope. Each line stays a complete schema-1 document with a narrower agents array; the emission cadence and the push/poll contract are unchanged. |
Push, poll, and what the stream promises
With a daemon running, subscribe rides its edge pushes (the same
wake-hint subscription tma wait uses): a state change wakes the stream, which
runs its own poll cycle and emits what that cycle observed, well under
--interval. Wake hints arriving within a 100 ms window coalesce into one
emission, and a slower belt cycle emits only when it observes a change, so a
quiet system emits nothing after the first snapshot. Every emitted document is
built from the subscriber’s own cycle, never from the socket, so push and poll
output are identical — and so are --changes-only and --events, which diff
the same cycles either way.
Degrade is invisible except as latency: no daemon, a daemon dying mid-stream,
or a daemon too old to answer the subscribe frame all drop the stream to
unconditional --interval polling, and a periodic re-probe picks a returning
daemon back up. There is no heartbeat — process death is the liveness signal, so
a consumer that owns the process respawns it on EOF. The stream exits only on a
signal or when its stdout closes; it prints one JSON document per line to stdout
and nothing else there.
Four things the stream deliberately does not do:
- No replay. A subscriber sees what happens from the moment it starts. There is no backlog, no cursor, and no way to ask for what you missed while your consumer was restarting.
- The first line is the current snapshot, not an event: in the default mode
it is the full document as of the entry cycle, and under
--eventsthere is no first line at all (see below). - Coalescing loses intermediate states. Pushes inside the 100 ms window
collapse into one cycle, so a pane that went
working→blocked→workingfaster than that emits nothing at all. The stream is level-triggered on each cycle’s observation, not a log of every instant. - The poll degrade is silent. Nothing is printed to stderr and the stream does
not exit; only latency changes. If you need to know which mode you are in,
tma doctorreports whether a daemon is running.
--changes-only
In poll mode the stream emits every --interval whether or not anything moved,
which is the pre-daemon self-poller contract: a consumer that just re-renders
does not care. A consumer that appends does — a daemonless logger writing to a
file gets 86,400 identical lines a day. --changes-only makes the poll tick
behave the way the push-mode belt already does: emit only when the document
differs from the last one sent.
It is a no-op in push mode (those wakes are already edges) and under --events
(edges are change-triggered by construction), accepted silently in both so a
script does not have to know which mode it landed in. The entry snapshot is
always emitted.
--events
Instead of snapshots, emit one record per state transition, one JSON object per line:
{"schema":1,"at_ms":1700000000000,"pane":"%5","agent":"claude","from":"working","to":"blocked","detail":"permission","locator":"work:1.0","repo":"app","branch":"main"}
| key | meaning |
|---|---|
at_ms | When the stream observed the transition (the diffing cycle’s clock), not necessarily when the agent changed. Coalescing and the poll interval both sit between the two. |
from / to | The state on each side, in the selector vocabulary: idle, working, blocked, unknown, done. |
detail, locator, repo, branch | The same values the row carries after the transition (null where the row’s are). |
The states are the disjoint reading: a finished-but-unreviewed pane is done,
not idle, so setting the attention flag on an idle pane is a real idle →
done edge, and anything that clears attention is done → idle: jumping to the
pane, navigating off it, or typing at it while it is on your screen. Read that
edge as “the user saw it”. The stream emits the idle → done edge before
applying its own clear, so a completion is always reported at least once, even
when the keystroke that retires it landed before the stream first saw the mark.
Two edges have an open end, spelled as the empty string:
- A pane that appeared since the last cycle emits
"from": "". - A pane that vanished emits
"to": "", carrying the fields from the last row seen.
The empty string, rather than unknown, is what makes those distinguishable: a
pane genuinely can be observed in unknown (it is there, its agent’s state is
unreadable), and a consumer must be able to tell that from “there was no pane”.
A pane whose state did not change emits nothing, even if its detail or title did — this is a transition stream, not a change feed.
There are no synthetic edges for the initial snapshot. The first cycle
establishes the baseline silently; the first line you see is a real transition. A
consumer starting fresh has no prior state to reconcile, and inventing "" →
working edges for panes that have been running for an hour would misdate them.
If you need the current state at startup, run tma ls --json once before (or
alongside) the stream.
With a selector, rows are filtered before the diff, so a pane leaving the
selection looks like a departure and one entering it looks like an appearance.
tma subscribe --json --events --repo app is a clean per-repo event feed as long
as you read it that way.
The jsonl logging recipe is in Stream state changes.
tma transcript
Read what the agent in a pane has been writing. Every state surface above tells you that a pane is blocked; this one tells you what it was doing when it stopped, out of the agent’s own transcript store. The events are normalized across stores, so a claude pane and an opencode pane answer in the same vocabulary.
Usage: tma transcript --pane <ID> [OPTIONS]
| option | meaning |
|---|---|
--pane <ID> | Required. The agent pane to read (e.g. %5). |
--last <N> | How many events to return, counting back from the newest (default 50). |
--before <CURSOR> | Return only events older than this cursor: the older a previous page reported. |
--headers | Drop bodies and cap every string at the header budget (256 bytes). Without it a local run carries each event’s body inline. |
--event <CURSOR> | Fetch one event’s body instead of a window. Excludes --last, --before and --headers. |
--subagent <ID> | Read a nested agent’s own transcript (the child_id a subagent_ref event carries). Claude only. |
--json | Emit the schema-1 document instead of one tab-separated line per event. |
Text mode is one line per event, newest first: kind, the store’s own timestamp, and the first line of the body.
$ tma transcript --pane %0 --last 5
user_message 2026-01-01T00:00:11.000Z can you check the failing test
compaction 2026-01-01T00:00:10.000Z compact_boundary
turn_boundary 2026-01-01T00:00:09.000Z end turn_duration
bookkeeping 2026-01-01T00:00:08.000Z mode
attachment 2026-01-01T00:00:07.000Z file (64 bytes)
tma: more before this page: --before t1.1000013.a3e191d3.e36.8c6.0
The older line goes to stderr, so a pipe gets only the events.
Which agents are served
Five stores are read: claude, codex, gemini, pi and
OpenCode. cursor-agent is refused by name rather than served empty
(store-incomplete); Agent transcript
stores is the argument for why, and what
else the reader cannot tell you.
Discovery prefers the pane’s @agent_transcript stamp (the path the agent’s own
hook payload named) and falls back to walking the store’s layout from
@agent_session. A pane detected from the screen alone, with no session id, has
neither, and is refused.
OpenCode takes neither path: one SQLite database
($XDG_DATA_HOME/opencode/opencode.db, or ~/.local/share/opencode/opencode.db)
holds every session, so the ses_* id in @agent_session is the whole of the
lookup and a pane without one is refused. The database is opened read-only and
kept open, never written and never checkpointed, and reading it needs no
sqlite3 on your PATH: tma links SQLite rather than driving the command. A
build of the crate without its opencode feature refuses those panes with
unsupported-store instead; the released binary has it on.
Paging
The window is end-anchored: --last counts back from the newest event, never
forward from the head. Take the older cursor a page reports and pass it back
as --before for the page behind it; repeat until older is null, which means
the head of the file is in the page you are holding. Pages never overlap and
never skip, including when a page boundary lands inside a record that produced
several events.
Cursors are opaque. They encode the file’s identity and its size when the cursor
was minted, so a cursor into a file that has since been compacted, truncated or
replaced is refused (cursor-invalid) rather than silently reinterpreted
against whatever now sits at that offset. Re-request without --before to get a
fresh window. An OpenCode cursor addresses a message timestamp and an index
inside it rather than a byte offset, since a database shrinks on checkpoint
without losing a row; it is refused the same way when it belongs to a different
database.
Three budgets bound one call: at most 1 MiB read from disk, at most 32 KiB of
headers returned, and at most 256 bytes per string. When one of them bites
before --last does, the page comes back short with budget_truncated set and
a usable older. A 44 MiB claude session costs the same first page as a 4 KiB
pi one.
--json
A schema-1 document. events is newest-first, and each event carries an opaque
cursor, its kind, the store’s ts, a preview (the body’s first line), the
keys that kind defines, and body (null under --headers).
{
"schema": 1,
"pane": "%0",
"agent": "claude",
"path": "/Users/you/.claude/projects/-Users-you-app/0f3c….jsonl",
"session": { "agent": "claude", "session_id": "0f3c…", "cwd": "/Users/you/app",
"version": "2.1.236", "model": null },
"older": "t1.1000013.a3e191d3.e36.c0c.0",
"budget_truncated": false,
"unknown": 0,
"events": [
{ "cursor": "t1.1000013.a3e191d3.e36.d20.0", "kind": "user_message",
"ts": "2026-01-01T00:00:11.000Z", "preview": "can you check the failing test",
"bytes": 30, "attachments": 0, "body": null }
]
}
The kinds are session_meta, user_message, assistant_text, thinking,
tool_call, tool_result, permission_request, turn_boundary, usage,
subagent_ref, compaction, attachment, bookkeeping and unknown. The
last two are the pair that matters for drift: bookkeeping is a record tma
knows and deliberately does not draw, unknown is one no adapter claimed, and
the document’s unknown count is how many of the latter this page held. A store
that grows a record type raises that count; it never fails the read.
--event <cursor> --json answers with the same envelope and a single event
key carrying that one event with its body filled in.
A refusal is a document too, so a --json consumer parses one shape either way:
{"schema":1,"pane":"%0","refusal":{"code":"store-incomplete","message":"…"}}
The codes are no-transcript, unsupported-store, store-incomplete,
cursor-invalid, record-too-large and io-error.
Exit codes
0 the window (or the body) was served
3 no such pane
4 a typed refusal: no transcript, a store this reader does not serve, or a stale cursor
1 a runtime failure
2 usage error
Exit 4 is separate from exit 1 on purpose: “there is nothing to show you, and here is why” is a different fact from “something broke”.
tma watch
Persistent live dashboard for a normal pane, tmux window, or terminal of its own
(not a popup): new-window "tma watch", or just tma watch in a spare terminal.
It shows the picker’s rows in a live-updating list, refreshing every second and
on a focus-change nudge. Enter jumps the acting client to the highlighted agent
and clears its attention but keeps the dashboard open (non-modal); q, Esc, or
ctrl-c quit. --temporary-session instead opens the dashboard in a dedicated
tmux session and closes that session after a jump or quit; the default prefix G
binding uses this mode.
a opens the action menu for the highlighted agent — the same
display-menu tma act --menu renders, but aimed at the pane under the cursor
rather than the one you are standing in, so a row of blocked agents is answered
without jumping to each. The menu is a tmux overlay: the list keeps refreshing
behind it, and nothing opens when no action is fireable on that pane.
The body adapts to the pane width. Below 76 columns it is a single list. At or
above 76 it splits, with a live preview of the highlighted pane beside the list;
press p to swap that preview for a full-width status table (glyph, agent,
state with detail, context gauge, time-in-state, locator, title, and a model
column when any visible pane stamps @agent_model), and p again to swap back.
The chosen body is session-local (never persisted).
Rows are in attention order, blocked → done → working → idle → unknown, longest-
in-state first within each rank. Both wide bodies then group them by repo
(worktrees roll up under their origin’s name), each group under a dimmed
▸ repo-name header, groups ordered by their most urgent member so the group
holding the pane that most needs you leads; every pane with no resolved repo
folds into one ▸ (no repo) group. Grouping is the default; press g to flatten
the list to the ungrouped attention order and g again to regroup
(session-local, like p).
Selection and Enter-jump target the agent under the cursor regardless of the
group headers. A dimmed branch label sits beside each row (table: a branch
column; single list: after the time column), present only when a visible pane
resolved one. The narrow single-list body stays flat but still shows the label.
Usage: tma watch [OPTIONS]
| option | meaning |
|---|---|
--table | Open directly in the full-width status table when the pane is wide enough (p toggles back to the preview). A pane below 76 columns still falls back to the single list. |
--temporary-session | Open in a dedicated one-use tmux session. Jumping or quitting exits the watcher and destroys that session; this is the default prefix G mode. |
| selector flags | Show only the agents in scope, e.g. a tma watch --repo app window per repo. |
A plain tma watch still runs wherever you put it. prefix G is the managed
one-use placement: it creates a temporary tmux session rather than leaving a
watch window in your current session. A split-window -h -l 40 'tma watch'
gives you a persistent pane beside your work, and a second terminal (or a second
monitor) works just as well, since tma watch reaches the server over the socket
like any other client. Every
instance advertises its pid in @tma_watch_pid on its own pane, which is what
the focus-change nudge signals; several at once are fine.
A scoped watcher still runs the unscoped poll cycle every second, so it remains a
full ambient producer for every pane on the server. Its first frame is painted
from stamps, which carry no repo label yet, so a --repo/--branch watcher
starts empty and fills in on the first refresh.
The invoking client comes from the global --client.
tma daemon
Run the event-hub daemon in the foreground. The daemon is strictly additive (tier 3): never required.
Usage: tma daemon [OPTIONS]
| option | meaning |
|---|---|
--ensure | Spawn a detached daemon if none is running for this server, then exit 0 (idempotent). |
--restart | Stop the daemon running for this server and start one from THIS binary, waiting until it answers. Starts one if none was running. Cannot be combined with --ensure. |
--stop | Stop the daemon for this server and leave it stopped. Detection falls back to the poll tier, which is strictly additive. Exit 0 when nothing was running. Mutually exclusive with --ensure and --restart. |
A resident daemon keeps the detection code it started with, so --restart is how
an upgraded tma takes effect on demand (reload re-reads config
and manifests, not the binary). It is unconditional in both directions: run it
from the older binary to go deliberately back.
[daemon] restart_on_upgrade, on by
default, does the same automatically before every surface and every tma event,
but only ever from a strictly newer build and only when a daemon is already
running.
The daemon is stopped with SIGTERM and never escalated to SIGKILL: it reaps its
tmux -C control clients only on a clean exit, so a killed daemon would leave one
behind per monitored session. A daemon that will not take SIGTERM is reported
rather than killed, and --restart exits nonzero without starting a replacement.
That report is not “nothing changed”: the SIGTERM has been delivered and stands,
so the daemon exits as soon as it unwedges. Start one again with --ensure once
it has gone: with the default autostart = false nothing else will, since
restart_on_upgrade only ever replaces a daemon and never starts one.
--restart also exits nonzero when the replacement was spawned but never
answered on the socket, which means it failed to come up — the usual cause is
something occupying the socket path. The bind happens before the daemon’s
control-mode probe, so a slow start is not mistaken for a failed one.
tma reload
Signal the running daemon to hot-reload its config and manifests (SIGHUP). It prints a no-op message if none is running for this server; one-shot surfaces and the picker reload on their own.
A reload is all-or-nothing: a config or manifest that does not parse leaves both
the running pair in place. Every surface that reloads names the failing file on
stderr, once per breakage rather than once per poll tick, so a mid-edit save is
quiet but a file left broken is not. A TUI (tma watch, the picker) holds its
line until the surface closes, so it cannot land on the alternate screen.
Usage: tma reload [OPTIONS]
Global options only.
tma init
First-run setup. It runs the commands below in order rather than reimplementing them, so every write still shows you its diff first and re-running changes nothing:
- Detect. Every bundled agent
install-hookscan wire is looked for on yourPATH, under the names its manifest gives (the manifest name plus itsprocess_names, minus generic ones likenode, which identify a runtime and not an agent). Found, not found, and “runs under a generic name, so tma cannot detect it” are all reported. - Wire each agent found, exactly as
install-hooks <agent>does. - Report the status line.
tmanever editsstatus-right: it is your format string, in whichever config set it. init says whether it already runstma status, and if not prints the line to add, the config file to add it to, and the reload command. - Install the keybindings, as
install-keysdoes. An install that is already current is skipped with a note. - Offer to restart a resident daemon of another build. A daemon already
running keeps the detection code it started with, so the wiring just written
would reach that build and not this one. Shown only when the versions actually
differ, and applied only on a
y(or--yes); declining is not a failure. - Start the daemon with
--daemon(whattma daemon --ensuredoes). - Report with
doctor, so you see the posture the steps above produced.
Usage: tma init [OPTIONS]
| option | meaning |
|---|---|
--yes | Apply every step without the interactive diff confirmations (scripts, tests). |
--daemon | Also start the event-hub daemon for this server. |
--no-daemon | Wire no daemon at all: omit the server-start launcher install-keys writes by default, and start none for this server. Cannot be combined with --daemon. |
--config-dir <DIR> | Override the tma config dir holding the managed tmux.conf and the per-server hooks-state-<server>.toml (env TMA_CONFIG_DIR). |
--conf <PATH> | The tmux config to mark with the keybindings source-file line, and the file the status-line instructions name. Same default as install-keys --conf. |
The per-agent config paths are not flags here; they resolve through the same
TMA_* environment ladder install-hooks documents, so
TMA_WRAPPER_PATH is how you move the wrapper off a read-only prefix that did
not ship one (the Nix package does; see install tma).
Exit code 1 if a step failed or a confirmation was declined; the closing doctor
report is informational and never changes it. With no terminal behind stdin and
no --yes every confirmation declines, which init says up front.
tma install-hooks
Install, uninstall, or verify the agent and tmux hook wiring.
Usage: tma install-hooks [OPTIONS] [AGENT]
[AGENT] is the agent whose config to wire (e.g. claude); it is optional only
with --check.
| option | meaning |
|---|---|
--all | Act on every agent that already carries tma wiring (the set bare --check inspects) instead of one named agent: repoints them all after an [install] wrapper_ref change or a moved binary, and with --uninstall unwires every one. It never wires an agent that was not wired before, so it cannot create a config for an agent you do not use. Naming an agent as well is a usage error. |
--uninstall | Remove tma’s hook wiring (symmetric to install). Removes every entry of tma’s for the wired events, including one left by an older install at a different wrapper path. |
--check | Verify hook wiring and report drift. Bare (--check) inspects every known agent; with an agent named, the drift report and exit code scope to that agent. The shared wrapper and tmux server hooks are always checked. |
--statusline | Also wire the statusline context shim (Claude, Cursor), which composes tma’s context intake into the agent’s own statusLine command. Opt-in: it edits a command you own. Recorded in statusline-state.toml, so later flagless runs keep it current. With --check, require it. |
--no-statusline | Remove the shim, restoring the command it wrapped, and clear the record. With --check, require its absence. |
--yes | Apply without the interactive diff confirmation (scripts, tests). |
--settings <PATH> | Override the agent settings path (env TMA_CLAUDE_SETTINGS). |
--gemini-settings <PATH> | Override Gemini’s settings.json path (env TMA_GEMINI_SETTINGS). Defaults to ~/.gemini/settings.json. |
--config-dir <DIR> | Override the tma config dir holding the per-server hooks-state-<server>.toml (env TMA_CONFIG_DIR). |
--wrapper-path <PATH> | Override where the tma-hook wrapper is written (env TMA_WRAPPER_PATH). |
--wrapper-ref <HOW> | How the agent configs name the wrapper: absolute (default) writes its full path, bare writes tma-hook for the agent to resolve off $PATH, which keeps one config working on every machine. Overrides [install] wrapper_ref for this run; with bare, install refuses when the name is not findable. See Configuration. |
--opencode-plugin <PATH> | Override where the OpenCode plugin is written (env TMA_OPENCODE_PLUGIN). |
--codex-config <PATH> | Override Codex’s config.toml path (env TMA_CODEX_CONFIG). Defaults to $CODEX_HOME/config.toml, else ~/.codex/config.toml. |
--codex-hooks <PATH> | Override Codex’s hooks.json path (env TMA_CODEX_HOOKS). Defaults to $CODEX_HOME/hooks.json, else ~/.codex/hooks.json. |
--cursor-hooks <PATH> | Override Cursor’s hooks.json path (env TMA_CURSOR_HOOKS). Defaults to ~/.cursor/hooks.json. |
--cursor-cli-config <PATH> | Override Cursor’s cli-config.json path, which holds the statusLine context shim (env TMA_CURSOR_CLI_CONFIG). Defaults to ~/.cursor/cli-config.json. |
--pi-extension <PATH> | Override pi’s extension file path (env TMA_PI_EXTENSION). Defaults to $PI_CODING_AGENT_DIR/extensions/tma.js, else ~/.pi/agent/extensions/tma.js. |
A completed install ends by checking the daemon: the hooks now point at this
binary, but a daemon already running for this server still carries the build it
started with, which is the build those hooks would reach. When the versions
differ, install offers to restart it, on the same confirm-before-changing terms as
every config write above (--yes accepts). Declining changes nothing and is not a
failure — tma daemon --restart is there when you are ready. --check and
--uninstall never make the offer.
Per-agent trust and wiring caveats (codex /hooks trust, gemini folder trust)
are in Agent coverage.
tma install-keys
Install, uninstall, or verify tma’s tmux keybindings. The bindings are written to
a managed file (~/.config/tma/tmux.conf, honoring XDG_CONFIG_HOME), and your
tmux config is given a single source-file ... # tma keys line. By default that line is
source-file -q "$XDG_CONFIG_HOME/tma/tmux.conf" "$HOME/.config/tma/tmux.conf", which tmux
expands when it loads the config, so the same tmux config works on another machine (-q
skips the XDG path quietly when the variable is unset). Pinning the dir with --config-dir
or TMA_CONFIG_DIR writes that literal path instead, double-quoted so a space in it still
parses. Install is
idempotent and diff-before-write; uninstall removes the managed file and that one
marked line, and touches no other binding. Uninstall exits non-zero if it cannot
remove the source-file line (a declined confirmation or an unwritable config),
naming the line you are left to remove by hand.
Usage: tma install-keys [OPTIONS]
| option | meaning |
|---|---|
--uninstall | Remove the managed file and the marked source-file line (symmetric to install). |
--check | Verify the managed file is current and the resolved tmux config sources it exactly once; report drift. A file with or without the mouse group counts as current; a file without the daemon launcher is drift unless --no-daemon says so. |
--mouse | Also write the root-table bindings that make the status-line counts clickable. With --check, require them instead of accepting either file. |
--no-daemon | Omit the run-shell line that starts the event-hub daemon for every tmux server that loads the file (written by default). With --check, stop requiring it. |
--yes | Apply without the interactive diff confirmation (scripts, tests). |
--conf <PATH> | The tmux config to mark with the source-file line. Defaults to the first tmux config that exists, in tmux’s own load order: ~/.tmux.conf, $XDG_CONFIG_HOME/tmux/tmux.conf, ~/.config/tmux/tmux.conf. With none of them present, tma creates $XDG_CONFIG_HOME/tmux/tmux.conf (or ~/.config/tmux/tmux.conf when ~/.config exists, else ~/.tmux.conf); it only ever creates a config when you have none, so the new file cannot shadow one. |
--config-dir <DIR> | Override the tma config dir holding the managed tmux.conf (env TMA_CONFIG_DIR). Defaults to ~/.config/tma. |
The default bindings are prefix-key bindings: a opens the picker in a popup,
G opens tma watch --temporary-session --table in a dedicated one-use session
(the full-width status table; g is taken by jump --blocked), A opens
tma act --menu on the active pane, and
j/g/b/h run tma jump with
--attention/--blocked/--back/--home. The status-line driver #(tma status)
is not written; add it to status-right yourself. See
Install the keybindings and the full
key tables.
--mouse adds four root-table bindings that dispatch on #{mouse_status_range}.
A left-click walks a three-arm chain, first match wins: the blocked count jumps to
the longest-blocked agent, any other tma:* range opens the picker popup, and
anything else falls
through to tmux’s own switch-client -t=. A right-click on any tma range opens
tma jump --menu. They need set -g mouse on, which tma never sets (it changes
copy/paste in every pane), and they claim tmux’s status-line mouse keys: a
left-click elsewhere still switches window, a right-click on a window name no
longer opens tmux’s window menu (Alt-right-click still does). tma doctor warns
when the bindings are installed but mouse is off. Full write-up in Clickable
status segments.
Every install ends with one more line, which --no-daemon omits:
run-shell -b 'tma --socket-path "#{socket_path}" daemon --ensure >/dev/null 2>&1'
The managed file is sourced when a tmux server loads its config, so this fires
once per server start and your servers run at tier 3 without being asked.
run-shell expands #{socket_path} to the socket of the server doing the
loading, so tmux -L work starts a daemon for itself rather than for the default
server a bare tma daemon --ensure would resolve. Nothing accumulates on a
re-source: --ensure takes a single-instance lock and exits 0 when a daemon
already holds it. The daemon exits on its own when its tmux server does, so there
is no matching stop line.
--no-daemon is a standing choice, not a one-off: a plain --check reports the
missing line as drift, so pair it (--check --no-daemon) in whatever script
verifies your setup. The daemon can still be started by hand with
tma daemon --ensure or lazily with [daemon] autostart = true; see
Run the daemon.
tma doctor
Diagnose each agent pane’s effective tier (3 daemon, 2 hooks, 1 polling) and why: hooks wired, daemon alive, last evidence source and age, and the ambient-driver check. Read-only.
Usage: tma doctor [OPTIONS]
| option | meaning |
|---|---|
--json | Emit JSON ("schema": 1) instead of the human-readable report. |
--exit-code | Exit 1 when the report carries a warning or a pane is below the tier its manifest supports. Without it doctor is a report: exit 0 unless the config fails to load or the server is unreachable. |
Beyond the per-pane tier, doctor reports the conditions that quietly disable a tier:
| check | what it means |
|---|---|
| tmux version | The server’s own #{version} against the 3.6 floor tma is tested on. Older servers load configs in a different order and expand display-popup differently, so a keybinding or the picker can misbehave for reasons nothing else in the report explains. A warning line only: it never counts toward --exit-code, and a version string tma cannot parse produces no warning at all. |
| attached clients | A #() status job only runs while a client draws the status line, so a server with none has no ambient polling floor. Reported as a warning only when no daemon is covering for it. |
global status | With status off, the #(tma status) driver never runs and display-message notifications are invisible. |
| clickable segments | The install-keys --mouse bindings are installed but the server’s mouse option is off, so no click can reach them. |
| tmux hooks | Per hook: present, stale (it runs a different command than this build installs, e.g. a moved binary), wiped (recorded but gone server-wide — a restart), or missing. |
process_names truncation | A manifest entry longer than the 15 characters both macOS libproc and the Linux kernel truncate comm to, with no truncated spelling beside it, can never match a pane. |
| hook demotion | A pane that registered through a hook (@agent_session stamped) whose current evidence came from capture: output kept arriving that its hooks did not account for. A working hook claim accounts for output until capture contradicts it, so a long tool call does not demote a healthy pane. |
| manifests and actions | Files the loader skipped, and actions naming an unknown agent. |
| remote panes | A pane whose foreground is a remote shell (ssh, mosh, docker, podman, kubectl). Neither the process walk nor a capture crosses that boundary, so an agent behind it reports only if its hooks can reach this tmux socket (Run an agent in a container). Any @agent_* options such a pane still carries are held, not refreshed. Reported, not warned about: running an agent elsewhere is a choice, not a misconfiguration. |
| unreadable stamps | A pane carrying an @agent_* option that does not decode. Every read path treats a corrupt stamp as no stamp, so the pane reads as never-stamped with nothing else to say why; doctor names the option and the value. tma debug explain prints the same fact for one pane. |
| stamps tma did not write | An @agent_state outside the closed token set, or one set with no @agent_stamped_at beside it, which no tma write produces. Both say a second tool is writing the pane’s state; doctor names the value and points at the @agent_state contract. Reported on the same line as an unreadable stamp, and counted the same way. |
Reading the report section by section, and the --exit-code CI recipe, are in
Diagnose with tma doctor.
tma completions
Write the completion script for one shell to stdout.
Usage: tma completions <SHELL>
<SHELL> is one of bash, zsh, fish, elvish, powershell.
The script is generated from tma’s own argument tree, so it covers every
subcommand, every flag, and every fixed value set — --state, --until,
--wrapper-ref, --format. It completes no runtime value: --agent,
--session, --repo, --branch, and an action name for tma act are not
offered, because a static script cannot know what your tmux server or your
config holds. The internal verbs (event, clear-attention, supervise) and
the internal daemon flags are left out.
Where each shell reads the file from, for a per-user install:
| shell | path |
|---|---|
| bash | ~/.local/share/bash-completion/completions/tma |
| zsh | _tma, in a directory on your $fpath (e.g. ~/.local/share/zsh/site-functions/_tma) |
| fish | ~/.config/fish/completions/tma.fish |
| elvish | anywhere, sourced from ~/.config/elvish/rc.elv |
~/.local/share/zsh/site-functions is not on zsh’s default $fpath. Add it
above compinit:
fpath=(~/.local/share/zsh/site-functions $fpath)
Or skip the file and evaluate the script at shell startup, at the cost of one
tma run per new shell:
eval "$(tma completions zsh)" # bash and zsh
tma completions fish | source # fish
You may not need any of this. A release tarball ships the four Unix scripts in
completions/, scripts/install.sh places them for the shells it finds (set
TMA_NO_COMPLETIONS=1 to skip), and the nix package installs them itself.
tma debug
Manifest-authoring and inspection tools.
Usage: tma debug [OPTIONS] <COMMAND>
| subcommand | summary |
|---|---|
redact | Redact a capture (paths, emails, and --pattern regexes) to stdout, preserving layout width, so it can be committed as a fixture. |
capture | Print exactly what the detector saw for a pane, in fixture format. |
explain | Run identity, the rule engine, and fold for a pane; print evidence, matched and failed rules, and the verdict. --json emits the versioned schema. |
transitions | Print the running daemon’s recent state transitions (its in-memory ring). --json emits the versioned schema. |
notify-test | Fire the notify command a trigger resolves to against a representative payload. --trigger blocked|done|context_high|stall (default blocked). |
stamp | Internal, unstable: apply a guarded stamp to a pane, for testing the pane-option write guards directly. Not a public interface. |
tma debug transitions
Reads the daemon’s bounded ring of recent state transitions over its socket:
$ tma debug transitions
transitions (3 held, cap 256, 12 recorded over the daemon's life):
%1 - -> working at=1700000000000 src=hook
%1 working -> blocked at=1700000001500 src=hook
Oldest first, - for a pane’s first observation. --json emits
{"schema":1,"cap":...,"recorded":...,"transitions":[...]} with from as an
explicit null.
The ring is daemon memory: it needs a running daemon (the command says so and
exits non-zero otherwise) and starts empty after a restart. A daemon older than
this build rejects the request and the command says to restart it — a reload
cannot add a protocol verb. For a durable per-notification record, use
[notify] log.
tma debug notify-test
A real notification is fire-and-forget with the command’s output discarded, which makes a broken hook silent. This subcommand runs the same command the same way, except that it waits, shows stderr, and reports the exit status:
$ tma debug notify-test --trigger blocked
payload {"schema":1,"agent":"claude","pane":"%0","state":"blocked",...}
command ~/.local/bin/tma-notify
exit 0
It needs no tmux server and no agent: the payload is synthesized (with repo and
branch resolved from the current directory) so a hook sees the real shape. It
exits non-zero when the trigger resolves to no command or the command failed,
so it works as a check. The outcome updates the same record tma doctor reads,
so a passing run clears a stale failure report.
tma version
Print version and build information (tma <version>).