Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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).

optionvaluemeaning
--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-timingPrint 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, --helpPrint help.
-V, --versionPrint 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.

optionvaluemeaning
--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

commandsummary
versionPrint version and build information.
lsList agent panes, one tab-separated line each (--json for the versioned schema).
statusPrint the status-line one-liner: state counts with glyphs and #[fg=] styling.
jumpJump focus to an agent pane across sessions (--attention / --blocked / --next / --back / --home / --pane), or menu them (--menu).
attachHand this terminal to the session holding a pane (--pane %5): select its window and pane, then replace this process with tmux attach-session.
waitBlock until the target reaches one of --until’s states, then print the matched row(s). One pane, or a fleet (--all / --count).
actFire a guarded action into an agent pane (--all for every pane in scope), or enumerate/menu the fireable ones (--list / --menu).
receiptsRead the dispatch ledger act --slot writes: what a dispatch ended as, without dispatching to find out.
serveAnswer one remote connection over stdio: NDJSON requests in, NDJSON responses and events out. Spawned by an ssh forced command, not typed.
devicePair, grant, revoke and list the devices tma serve will answer. The whole write side of the remote scope model.
muteSuppress notifications for the panes in scope, for --for <DURATION> or until --clear.
subscribeStream the read path: one complete ls --json document per line, pushed when a daemon is present.
transcriptRead what the agent in a pane has been writing, as normalized events, newest first, a bounded page at a time.
watchPersistent live dashboard for a pane, window, or terminal of its own.
daemonRun the event-hub daemon in the foreground; --ensure spawns it if absent then exits.
reloadSignal the running daemon to hot-reload its config and manifests (SIGHUP).
initFirst-run setup: detect your installed agents and wire their hooks, install the keybindings, print the status-right line, then report with doctor.
install-hooksInstall, uninstall, or verify the agent and tmux hook wiring.
install-keysInstall, uninstall, or verify tma’s tmux keybindings.
doctorDiagnose each agent pane’s effective tier and why.
completionsPrint a shell completion script on stdout (tma completions zsh).
debugManifest-authoring and inspection tools.
eventInternal, unstable: bridge one agent hook event to a stamp.
clear-attentionInternal: 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.
superviseInternal: 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]
optionmeaning
--jsonEmit JSON ("schema": 1) instead of tab-separated lines.
--pane <ID>List only this pane id (e.g. %5), the single-row form.
selector flagsNarrow 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]
optionmeaning
--format <FORMAT>Output form: tmux (default), plain, json, or prom.
selector flagsCount 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.

formatoutput
tmuxThe default status-line one-liner, glyphs with #[fg=] styling plus the clickable-range markers. No trailing newline.
plainThe 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.
promPrometheus 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]
optionmeaning
--attentionJump 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.
--blockedJump to the longest-blocked agent.
--nextJump to the next agent after the current pane (session, then window, then pane order).
--backReturn one step along the trail (the previous jump’s origin).
--homeReturn 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.
--menuRender 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 flagsScope 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]
optionmeaning
--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.
--printPrint 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:

codemeaning
0attached (the exec does not return), or --print printed the argv
2stdin is not a terminal, so there is no tty to hand over
3the pane vanished, whether before the selects or between them and the attach
1a 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:

optionmeaning
--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.
--anyWait 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.
--allBarrier: 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:

optionmeaning
--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 flagsScope --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.
--jsonEmit 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

codemeaning
0A target state was observed (the row on stdout; --json for one schema-1 object, or the agents document under --all/--count).
124Timed out (--timeout elapsed); nothing on stdout.
3A watched pane vanished while waiting: a --pane, a pinned --agent, or an --all member. --any and --count keep waiting for the others.
4The 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.
2Usage error (bad --until token, no target named, an invalid target combination, or --all whose scope matched no pane).
1A 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.

optionmeaning
--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 flagsScope 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.
--allFire on EVERY selector-matched pane, one after another.
--dry-runPrint 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.
--forceSkip 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).
--yesSatisfy a confirm action non-interactively (a non-TTY without --yes refuses). Under --all it covers the whole batch.
--jsonEmit schema-1 JSON: the fire result object (the results envelope under --all), or the --list document.
--listEnumerate actions; with --pane, include each one’s fireability verdict.
--menuRender 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 locked refusal (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, error included: a broker failure cannot prove the keystroke did not land, so its receipt records fired-unknown and 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, 0600 under 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: tma names 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 confirm action asks once for the batch, listing the panes, rather than once per pane; --yes satisfies the batch.
  • --json emits the results envelope, 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 exits 0 only 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 batch id 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:

keytypemeaning
schemanumberact-log schema version (1), versioned separately from the --json result
atnumberepoch ms the fire completed
panestringtarget pane id
agentstring | null@agent_name as read under the lock; null when the pane vanished before any read
actionstringthe action name
kindstringkeys, api, hook, text, or exec: the transport the fire used, not just the manifest kind
outcomestringthe --json outcome vocabulary
reasonstring | nullthe refusal or vanish token, null for every other outcome
sourcestringwhich 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_msnumber | nullthe pane’s episode instant (max(@agent_since, @agent_turn_at)), read under the lock
repeatnumberconsecutive fires of this action on this pane in this episode, counting this one; 0 when the fire never reached the effect
pending_toolstring | null@agent_pending_tool: which tool the open prompt is about
pending_callstring | null@agent_pending_call: its call id
allbooleanwhether the fire came from --all
batchstring | nullthe 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

codemeaning
0Acted: 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.
124A synchronous exec child was killed at timeout_ms.
4The 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.
5The pane action lock is held by another invocation.
3The 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.
2Usage error (bad flag combination, selector flags alongside --pane, or --all whose selector matched no pane).
1A 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]
optionmeaning
--slot <ID>Only the receipt for this slot id.
--since-ms <MS>Only dispatches claimed at or after this epoch-ms instant (inclusive).
--jsonEmit 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>
optionmeaning
--stdioSpeak 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]
optionmeaning
--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.
--onlyOn 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.
--jsonOn list, emit the schema-1 document instead of one line per device.

The four scopes

scopegrantsgranted at pairing
readThe fleet, transcripts, receipts, and cards rendered but inert. Implicit: every paired device holds it.yes
act:answerapprove, deny, question_reply, question_reject.yes
act:steersteer, steer_now, interrupt, deny_with_message.yes
act:alwaysapprove_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]
optionmeaning
--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.
--clearLift the mute on the matched panes.
selector flagsMute 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.

codemeaning
0The option was written (or unset) on every target.
3The selector matched no agent pane.
2Usage error (bad --for value, --for with --clear, selector flags alongside --pane, or no target and not inside tmux).
1A 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]
optionmeaning
--jsonRequired. 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-onlySkip a poll-mode emission that would repeat the last document.
--eventsEmit one edge record per state transition instead of snapshots.
selector flagsEmit 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 --events there 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 workingblockedworking faster 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 doctor reports 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"}
keymeaning
at_msWhen 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 / toThe state on each side, in the selector vocabulary: idle, working, blocked, unknown, done.
detail, locator, repo, branchThe 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 idledone edge, and anything that clears attention is doneidle: 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 idledone 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]
optionmeaning
--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.
--headersDrop 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.
--jsonEmit 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]
optionmeaning
--tableOpen 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-sessionOpen 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 flagsShow 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]
optionmeaning
--ensureSpawn a detached daemon if none is running for this server, then exit 0 (idempotent).
--restartStop 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.
--stopStop 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:

  1. Detect. Every bundled agent install-hooks can wire is looked for on your PATH, under the names its manifest gives (the manifest name plus its process_names, minus generic ones like node, 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.
  2. Wire each agent found, exactly as install-hooks <agent> does.
  3. Report the status line. tma never edits status-right: it is your format string, in whichever config set it. init says whether it already runs tma status, and if not prints the line to add, the config file to add it to, and the reload command.
  4. Install the keybindings, as install-keys does. An install that is already current is skipped with a note.
  5. 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.
  6. Start the daemon with --daemon (what tma daemon --ensure does).
  7. Report with doctor, so you see the posture the steps above produced.
Usage: tma init [OPTIONS]
optionmeaning
--yesApply every step without the interactive diff confirmations (scripts, tests).
--daemonAlso start the event-hub daemon for this server.
--no-daemonWire 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.

optionmeaning
--allAct 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.
--uninstallRemove 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.
--checkVerify 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.
--statuslineAlso 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-statuslineRemove the shim, restoring the command it wrapped, and clear the record. With --check, require its absence.
--yesApply 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]
optionmeaning
--uninstallRemove the managed file and the marked source-file line (symmetric to install).
--checkVerify 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.
--mouseAlso write the root-table bindings that make the status-line counts clickable. With --check, require them instead of accepting either file.
--no-daemonOmit 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.
--yesApply 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]
optionmeaning
--jsonEmit JSON ("schema": 1) instead of the human-readable report.
--exit-codeExit 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:

checkwhat it means
tmux versionThe 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 clientsA #() 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 statusWith status off, the #(tma status) driver never runs and display-message notifications are invisible.
clickable segmentsThe install-keys --mouse bindings are installed but the server’s mouse option is off, so no click can reach them.
tmux hooksPer 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 truncationA 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 demotionA 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 actionsFiles the loader skipped, and actions naming an unknown agent.
remote panesA 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 stampsA 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 writeAn @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:

shellpath
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
elvishanywhere, 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>
subcommandsummary
redactRedact a capture (paths, emails, and --pattern regexes) to stdout, preserving layout width, so it can be committed as a fixture.
capturePrint exactly what the detector saw for a pane, in fixture format.
explainRun identity, the rule engine, and fold for a pane; print evidence, matched and failed rules, and the verdict. --json emits the versioned schema.
transitionsPrint the running daemon’s recent state transitions (its in-memory ring). --json emits the versioned schema.
notify-testFire the notify command a trigger resolves to against a representative payload. --trigger blocked|done|context_high|stall (default blocked).
stampInternal, 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>).