tmux-agents
tma is a self-hosted companion for the coding agents you run in tmux. It finds
them in your panes, shows which are blocked, working, or idle, takes you to the
one that needs you, and answers the prompt it is stopped on. Six agents are
covered by the same commands. State lives in tmux pane options, so any tmux show-options or #{@agent_state} format string reads it directly and tma ls --json gives a stable structured feed, and nothing sits between you and your
agents but your own machine.
If you are sizing tma up rather than using it yet, read Why
tma first: the problem, the two other shapes this tool
could have taken, what being cross-agent and self-hosted buys, and the one choice
everything else follows from.
This site is a Diátaxis tree, organized in four parts:
- Tutorial takes you from an empty tmux to a working monitor: getting started.
- How-to guides are task-oriented recipes: installing
tmaitself, installing hooks, adding a custom agent, running an agent in a container, running tma over ssh, notifications, authoring a custom action, blocking a script on agent state, streaming state changes, running the daemon, showing agents in your status line, driving an external bar, installing the keybindings, diagnosing withtma doctor, and reading agent state from a status bar or script. - Reference documents the contracts precisely: the command-line interface,
the keybindings, the
config.tomlkeys, the pane options and JSON schemas, per-agent hook coverage, and the agent and action manifest schemas. - Explanation covers the why: why tma, the architecture, the detection model, and the security model.
The development record (the daemon and architecture decision notes, the numbered
requirements) is not part of this site. It lives in the repository under
docs/internal/
for contributors, as design history rather than user documentation.
In a hurry? tma init is the fast path: it detects the agents you have
installed, wires their hooks, installs the keybindings, prints the status-line
entry, and ends with a tma doctor report (reference).
New here? Start with the getting-started tutorial, which walks the same ground one command at a time. Looking for a specific contract? Jump to the command-line interface or configuration.
Why tma
Coding agents run as long-lived interactive TUI processes. Anyone running more than one hits the same failure: an agent goes blocked on a permission prompt in some background window and sits there for twenty minutes while you work in another session, unaware. The state you need is on a screen nobody is looking at.
There are three ways to answer that: replace the multiplexer, wait for each agent vendor to ship its own remote, or read the multiplexer you already run. tma takes the third. This page is the comparison and the one architectural choice that follows from it.
Replace the multiplexer
herdr makes the monitor a terminal multiplexer with agent detection built in. It owns the PTYs, so it sees everything: byte-level activity, the live screen, process lifetime, no polling anywhere.
It works, and the cost is that it replaces tmux. For anyone with an established tmux workflow (a sessionizer, worktrees as windows, custom keybindings, a status-line integration) adopting it means nesting one multiplexer inside another: two prefix keys, two detach models, two session stores, and panes the outer tmux cannot see.
The part worth keeping is not the multiplexer. It is the detection model: identify the agent by process name, read its state from the terminal screen through declarative per-agent rules, and arbitrate between evidence sources. Everything built to support that (PTY ownership, session persistence, pane management) tmux already provides.
Wait for the vendor
The other answer is the agent vendor’s own remote: a first-party app or web surface that reaches the session you started on your machine. Claude Code has one. It is a better fit than tma for one thing, which is that vendor’s own agent, and it is worth using for that.
Two properties keep it from being the whole answer. It covers one vendor’s agent, and a working week is usually more than one: Claude Code in this repo, Codex in that one, OpenCode on the box in the closet. And what a first-party remote lets you do remotely is not everything the terminal lets you do. Claude Code’s Remote Control cannot answer a permission dialog from the phone; the dialog is a terminal surface, so the answer has to be typed where the terminal is. The prompt sitting unanswered is exactly the state that stopped the work.
That is the case tma covers: answer the prompt, from any agent, from anywhere.
Where tma sits
tma keeps tmux and adds three things to it. Agent-agnostic discovery, a process walk plus per-agent manifests, means a hookless agent is detected anyway from its process and its screen. Hook integration means a cooperative agent reports state the instant it changes instead of a poll later. Cross-session navigation means the picker lists and jumps to agents anywhere on the server, not only in the session you are attached to.
The three tiers stack rather than compete: one-shot commands work alone, hooks cut latency to zero for the agents that have them, and the daemon is strictly additive on top. Consumers cannot tell which tier produced a verdict, because all three write the same place. Which brings up the choice the whole design rests on.
tmux is the state store
Every verdict tma reaches is written back onto tmux as pane and window user options:
set -p -t %13 @agent_name claude
set -p -t %13 @agent_state blocked
set -w -t mysession:2 @agent_summary "blocked:1"
Once state lives there, integration is ordinary tmux configuration rather than a
private protocol. window-status-format colors a window red when its
@agent_summary says an agent is blocked. status-right renders a fleet
summary. tmux hooks and if -F conditionals react to a state change. Any other
tool reads the same options with show-options, and needs no agreement with tma
about anything.
This is what a monitor with its own client socket cannot offer, and the
difference is not throughput. It is that there is no protocol to version: tmux
formats are the API, and they were stable before tma existed. A reader written
against #{@agent_state} keeps working across every tma release, because tma is
not in the read path at all. The pane option
schema writes that promise down, and
tma ls --json is the same contract for consumers that want a resolved row
rather than a raw option.
It also means the store outlives the writer. Kill every tma process and the last verdict is still on the panes, still readable, still rendering in your status line. Nothing has to be running for state to exist.
What tmux already provides
Each capability a PTY-owning monitor has to build has a tmux equivalent tma reads instead:
| what a PTY-owning monitor builds | what tma reads |
|---|---|
| pane process probe | #{pane_pid} and a process-tree walk |
| bottom-of-buffer screen snapshot | capture-pane -p -e -t %id -S -<N> |
| OSC title, where agents put spinners and state | #{pane_title} |
| PTY activity signal | #{window_activity}, and control-mode %output edges |
| state storage and event bus | pane and window user options |
| session persistence, detach and attach | tmux itself |
The detection core reduces to pure functions over a snapshot, which is why the part most likely to be subtly wrong is testable without a tmux server or a running agent. Every bundled screen rule ships with a captured fixture that proves it fires.
What it costs
tmux tells tma less than owning the PTY would, and the honest accounting is short. Activity is window-granular rather than per-pane. Capture is poll-based rather than streamed. A pane scrolled into copy mode is showing history, so tma freezes its state rather than matching against it.
The residual risk is a working agent with a quiet screen and no title spinner
reading as idle for a cycle or two. That is accepted, because the state worth
being right about is blocked, and blocked is the one an agent makes loud: it
paints a prompt on the screen and, for most agents, fires a hook as well. The
detection model covers the arbitration in full, including
where it deliberately holds a stale answer instead of guessing.
What this shape gives you that the others do not
Four things follow from being cross-agent and self-hosted, and none of them is available from a monitor built around one vendor’s agent or one vendor’s server.
One fleet, six agents. tma ls, the picker, the status line and tma act take
the same arguments whichever agent is in the pane. Adding a seventh is a TOML
manifest, not a patch.
Transcripts without cooperation. tma transcript reads what four of the six
agents (claude, codex, gemini, pi) have been writing, out of the files they already
keep on disk, normalized so the panes answer in the same vocabulary. None of those
agents was asked for an API, a plugin, or a protocol. The two that are refused are
refused by name, because “nothing happened” is a claim and it would be false.
Consent that is never context-free. An approval is only meaningful if the thing
being approved is in front of you. approve and deny are gated on detail = permission, so a dialog that wants you to choose an option rather than grant a
request offers no approve key; the gate is re-asserted inside the pane’s action lock
against the same read it was quoted from, so a pane that moved on refuses instead of
landing your answer on the prompt that replaced it; and a notification navigates to
the pane rather than acting on it.
No third party on the path. State is written to your tmux server and read back from it. Nothing is relayed through a service, there is no account to have, and the transcript reader opens files that were already on your disk.
What tma is not
- Not a multiplexer, a terminal emulator, or a session manager. It never owns a PTY.
- Not a project navigator. It navigates agents; a sessionizer navigates repos, and they coexist under different keybindings.
- Not an orchestrator. It observes agents and answers prompts you aim at them; it does not spawn them or drive them at each other.
- Not a replacement for a vendor’s own remote control of that vendor’s own agent. Where one exists it is the better tool for that agent, and the two coexist.
- Not a way to watch agents outside tmux. A pane is the unit, and an agent in a
display-popupis invisible for the same reason: tmux does not enumerate it.
See also
- Architecture for the crate boundaries that hold these rules up.
- The detection model for how a verdict is actually reached.
- Getting started to see the whole loop in a terminal.
Getting started
This tutorial takes you from an empty tmux to a working agent monitor. You will
build tma, wire one agent (Claude Code) so its state is reported the instant it
changes, and learn the loop the tool is built around: see who is blocked, jump to
them, and clear the flag. Every command below is real; run them as you read.
You need tmux (3.2 or newer), a Rust toolchain, and Claude Code installed. The
commands assume a POSIX shell.
1. Build the binary
Clone the repository and install from the clone:
$ git clone https://github.com/pperanich/tmux-agents
$ cd tmux-agents
$ cargo install --path crates/tma
This puts a tma binary on your PATH (in ~/.cargo/bin). Check it:
$ tma --version
tma 0.5.15
If you would rather install from the Nix flake or the Home Manager module, take
the detour through install tma and come back with a
tma on your PATH.
Running tma with no subcommand opens the picker, and tma --help lists every
command. You will meet the important ones below.
2. Wire Claude Code
Wiring an agent installs state-reporting hooks into its config, so it reports state as it works. For Claude Code that is one command:
$ tma install-hooks claude
tma: proposed change to ~/.claude/settings.json (agent hooks):
- {}
+ {
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "~/.cargo/bin/tma-hook claude SessionStart"
+ }
+ ]
+ }
+ ],
+ "SessionEnd": [ ... "tma-hook claude SessionEnd" ... ],
+ "UserPromptSubmit": [ ... "tma-hook claude UserPromptSubmit" ... ],
+ "PreToolUse": [ ... "tma-hook claude PreToolUse" ... ],
+ "PostToolUse": [ ... "tma-hook claude PostToolUse" ... ],
+ "Notification": [ ... "tma-hook claude Notification" ... ],
+ "Stop": [ ... "tma-hook claude Stop" ... ],
+ "SubagentStart": [ ... "tma-hook claude SubagentStart" ... ],
+ "SubagentStop": [ ... "tma-hook claude SubagentStop" ... ]
+ }
+ }
Apply this change? [y/N] y
tma: installed hooks for claude
tma prints the exact diff and applies it (pass --yes to skip the confirmation
in scripts). The command also installs two tmux server hooks (they show up in
step 8’s tma doctor output).
Verify the wiring:
$ tma install-hooks claude --check
tma: hooks OK
Now start Claude Code in a tmux pane and give it a task. Its SessionStart hook
registers the pane, and every prompt, tool call, and permission prompt updates
the pane’s state as it happens.
3. See state in tma ls
Open two or three tmux windows and run Claude in a couple of them. Give one of
them a long task so it stays busy. Then provoke a blocked agent on purpose
instead of waiting for one: in the other pane, ask Claude to run a shell command,
say run "ls -la" for me. Claude Code stops and asks for approval before running
a command it has no standing permission for, and that halt is the blocked
state. Leave the prompt sitting there unanswered, switch to a third window, and
list the agent panes:
$ tma ls
%1 claude blocked permission 1786900866503 s2:0.0 web-ui 1 web-ui main
%0 claude working 1786900866412 s1:0.0 api-server api fix/timeout
One tab-separated line per agent pane:
pane agent state detail since session:window.pane title attention muted repo branch worktree. Here %1 is blocked on a permission prompt and %0 is
working. The 1 after the blocked row’s title is the attention flag (step 8);
the empty column after it is tma mute, which
neither pane is under. The last three columns are the pane’s git checkout, all
empty for a pane that is not in one. For a machine-readable feed, add --json:
$ tma ls --json
{"schema":1,"agents":[{"pane":"%1","agent":"claude","state":"blocked","detail":"permission","since":1786900866503,"since_ms":1786900866503,"episode_ms":1786900866503,"locator":"s2:0.0","title":"web-ui","attention":true,"done":false,"session":"b47e5d18-2a90-4c3f-8de6-71f0c9a2b845","context":18,
…
That is one row, cut short: each carries twenty-one keys, the ones above plus
context_at_ms, muted, tokens, repo, branch, worktree, server, and
host. The full column and JSON contract, with the type and meaning of every key,
is in Pane options and JSON contracts.
4. Put the counts in your status line
tma status prints a one-line summary with glyphs and tmux color codes:
$ tma status
#[range=user|tma:blocked]#[fg=red]⚑1#[norange] #[range=user|tma:working]#[fg=yellow]●1#[norange]
That is one blocked and one working agent. tmux renders the #[fg=...] codes as
color when this runs from status-right, and draws nothing for the #[range=…]
markers, which only matter if you opt into clickable
segments. Add it to
your tmux config (~/.tmux.conf or ~/.config/tmux/tmux.conf):
set -g status-right '#(tma status) %H:%M'
Reload tmux so it picks the line up, naming whichever file you just edited:
$ tmux source-file ~/.config/tmux/tmux.conf
The right-hand end of your status line should now read ⚑1 ●1: a red flag for
the agent still parked on its approval prompt, and a yellow dot for the one
working. tmux redraws it on its own status-interval (15 s by default), so give
it a moment.
Keeping it in status-right does more than display counts; the tier check below
and run-the-daemon cover why.
5. Open the picker
Run tma with no arguments (or bind it to a key, step 7):
tma
The picker is a fuzzy list of every agent pane across all your sessions, blocked
ones sorted first. Each row leads with the state glyph, then the agent name,
session:window.pane, and time in state, then a dimmed branch label when the pane
resolves one, and finally the pane title. A live preview of the highlighted pane
sits beside the list. Type to filter, use the arrow keys to move, Enter to jump
to the highlighted agent, tab for that agent’s action menu, Esc to cancel.
Every printable key types, so any agent name is searchable from the first
keystroke.
6. Open the watch dashboard
The picker is modal: it closes when you jump. For an always-on view, tma watch
is a persistent dashboard. Give it a tmux window of its own:
$ tmux new-window 'tma watch'
It shows the same rows as the picker, refreshing every second and immediately
when you change panes. Enter jumps the acting client to the highlighted agent
and leaves the dashboard running where it is; q, Esc, or ctrl-c quit. Step
7 puts it on a key.
A window is one placement, not the only one. A spare terminal outside tmux works
(tma watch talks to the server over its socket), and so does a split beside
your work — with the caveat that a split stays in the window you opened it in
when you jump somewhere else. tma does not place it for you.
7. Jump to whoever needs you
The whole point is closing the gap between “an agent is blocked” and “you are
looking at it”. tma jump --blocked moves focus to the longest-blocked agent
across every session, no picker needed. Rather than hand-writing bindings, have
tma install its own:
$ tma install-keys
tma: proposed change to ~/.config/tma/tmux.conf (tma keybindings):
@@ -0,0 +1,9 @@
+# tma keybindings, managed by `tma install-keys`. Do not hand-edit; re-run to update,
+# or `tma install-keys --uninstall` to remove.
+bind-key a display-popup -E -w 80% -h 60% 'tma'
+bind-key G run-shell 'tma watch --temporary-session --table --client "#{client_name}"'
+bind-key j run-shell 'tma jump --attention --client "#{client_name}"'
+bind-key g run-shell 'tma jump --blocked --client "#{client_name}"'
+bind-key b run-shell 'tma jump --back --client "#{client_name}"'
+bind-key h run-shell 'tma jump --home --client "#{client_name}"'
+bind-key A run-shell 'tma act --menu --pane "#{pane_id}"'
Apply this change? [y/N] y
tma: proposed change to ~/.config/tmux/tmux.conf (tma keys source-file):
@@ -0,0 +1 @@
+source-file -q "$XDG_CONFIG_HOME/tma/tmux.conf" "$HOME/.config/tma/tmux.conf" # tma keys
Apply this change? [y/N] y
tma: installed keybindings (~/.config/tma/tmux.conf). Reload with `tmux source-file ~/.config/tmux/tmux.conf`.
tma: reminder: add `#(tma status)` to your `status-right` for the ambient state driver (tma does not edit status-right).
The bindings live in their own managed file; your tmux config gets one line that
sources it, and tma install-keys --uninstall takes both away again. The closing
reminder is unconditional, and you already did that part in step 4. Reload the
config it named:
$ tmux source-file ~/.config/tmux/tmux.conf
Press prefix g and you land on the blocked pane. prefix j goes to whoever
wants you next (blocked first, then finished-but-unreviewed), prefix b returns
to where you jumped from, prefix a opens the picker in a popup, and prefix G
opens the step-6 dashboard straight into its full-width table in a dedicated
temporary session. Jumping or quitting closes that session, so no dashboard
window remains in the session where you pressed G. See
the keybindings reference for the rest of the set,
and Install the keybindings for rebinding
any of them.
8. Understand the attention lifecycle
Look again at the tma ls output in step 3: the blocked row ended in 1, the
attention flag. It marks a pane that changed to something you have not looked at
yet, and it clears the moment you focus the pane. Jump to a flagged pane (or
switch to it manually) and watch its glyph revert: a blocked agent’s ⚑ or a
finished agent’s done ✓ drops back to a plain idle ○. That is the loop, an
agent flags for attention, the surfaces show it, you jump, the flag clears. For
how attention is set and why it is not a fifth state, see
the detection model.
Confirm the tier you are running at with tma doctor:
$ tma doctor
daemon: not running (<tmpdir>/tma/<server>.sock) — tier 3 needs a running daemon (`tma daemon --ensure`)
ambient: polling — `tma status` last ran 3.5s ago
clients: 1 attached
watch: 1 watcher running (nudged on focus change)
hooks: after-select-pane ✓ session-window-changed ✓
wrapper: ~/.cargo/bin/tma-hook ✓
agents: 6 loaded, no issues
actions: 4 loaded, no issues
panes (2):
%0 claude s1:0.0 tier 2 working (hook, 3.1s ago)
hooks: wired
not tier 3: daemon not running (events direct-stamp; run `tma daemon --ensure` for the daemon tier)
%1 claude s2:0.0 tier 2 blocked (hook, 3.0s ago)
hooks: wired
not tier 3: daemon not running (events direct-stamp; run `tma daemon --ensure` for the daemon tier)
The ambient: polling line is the status-right edit from step 4 doing its second
job. Had you skipped it, that line would read NOT polling and the ambient
surfaces would go stale between hook events.
You now have hook-fresh state (tier 2) with no background process. That is the whole tool for a single-user setup.
Where to go next
- To cover more agents, or agents
tmadoes not ship a mapping for, see install-agent-hooks and add-a-custom-agent. - To answer a blocked pane instead of only finding it, see
tma act:approve,deny,interrupt, andsteer --textfor a line of your own. On a claude pane the answer rides the hook reply lane, on by default, so no keystroke lands in the pane at all. - To see what an agent was doing when it stopped, read its own transcript with
tma transcript. - To reach a pane from a terminal that is not a tmux client yet, such as a fresh
ssh session, use
tma attach --pane. - To get desktop notifications and blocked-agent alerts even when you are looking
elsewhere, see notifications and
run-the-daemon.
[notify] stallcovers the other failure, a pane that has been working far longer than it should be. - To understand how
tmadecides a pane is blocked, and why it trusts hooks over the screen, read the detection model. - For the crate layout, the tier story, and where state actually lives, read the architecture.
Install tma
Four ways to get the binary: the install script, cargo install from a clone,
the Nix flake, or the Home Manager module. All four land the same single tma
executable, so pick the one that matches how you manage the rest of the machine.
You need tmux 3.6 or newer. That is the release tma is developed and tested
against; older servers load configs in a different order and expand
display-popup differently, so the keybindings and the picker can misbehave.
tma doctor warns when the server it is talking to is older, and keeps working.
Installing the binary is the whole install. Hooks (tma install-hooks <agent>)
and keybindings (tma install-keys) are separate commands you run afterward,
because both edit files you own and both show you the diff first.
tma init runs that whole sequence for the
agents it finds on your PATH, if you would rather do it in one command.
With the install script
The shortest path, and the one that needs no toolchain. It works from the first public release onward, because it downloads a release artifact:
$ curl -fsSL https://raw.githubusercontent.com/pperanich/tmux-agents/main/scripts/install.sh | sh
The script picks the build for your platform, verifies the tarball against the
release’s SHA256SUMS, installs the binary to ~/.local/bin, and prints a
PATH hint if that directory is not on yours. It never uses sudo. To upgrade,
run it again: the new binary replaces the old one in place.
Prebuilt binaries cover macOS on Apple Silicon and Intel, and Linux on x86_64 and aarch64. The Linux builds are static musl binaries, so your distribution’s glibc does not come into it. On any other platform the script stops and points you at the cargo path below.
It also installs shell completions, for whichever of bash, zsh, and fish it
finds on your machine, into that shell’s per-user directory. zsh needs one more
line in your ~/.zshrc for its directory to be searched at all, which the
script prints when it applies.
Three environment variables change what it does:
| variable | effect |
|---|---|
TMA_VERSION | Install this tag instead of the latest release. |
TMA_INSTALL_DIR | Install here instead of ~/.local/bin. Created if it does not exist. |
TMA_NO_COMPLETIONS | Set to anything to install only the binary. |
They belong on the sh, not on the curl, or the pipeline hands them to the
wrong process:
$ curl -fsSL https://raw.githubusercontent.com/pperanich/tmux-agents/main/scripts/install.sh \
| TMA_VERSION=v0.5.15 TMA_INSTALL_DIR=~/bin sh
If piping a script into a shell is not your habit, read it first and run it from disk.
From a clone, with cargo
You need git and a Rust toolchain (1.88 or newer):
$ git clone https://github.com/pperanich/tmux-agents
$ cd tmux-agents
$ cargo install --path crates/tma
The workspace builds one binary. Check it landed on your PATH:
$ tma --version
tma 0.5.15
To upgrade, git pull and run the same cargo install again; it replaces the
binary in place.
cargo install places a binary and nothing else, so wire the completions
yourself — tma completions <shell> writes the script for one shell to stdout,
and tma completions says where each
shell wants it. The install script and the Nix package both do this for you.
From the Nix flake
The flake builds for x86_64-linux, aarch64-linux, x86_64-darwin, and
aarch64-darwin, and exposes:
| output | what it is |
|---|---|
packages.<system>.tma, packages.<system>.default | the tma package, versioned from the workspace Cargo.toml |
overlays.default | adds tma to a nixpkgs instance |
homeModules.default, homeModules.tma | the Home Manager module below (homeManagerModules is an alias for older setups) |
devShells.default | the contributor shell: the package’s build inputs plus clippy, rustfmt, rust-analyzer, tmux, mise, and mdbook |
checks.<system>.tma | the package build, whose test suite spawns its own scratch tmux server |
formatter.<system> | nixfmt-rfc-style, so nix fmt formats the Nix files |
Try it without installing anything, or install it for real:
$ nix run github:pperanich/tmux-agents -- --version
$ nix profile install github:pperanich/tmux-agents
There is no apps output; nix run resolves packages.default, whose
meta.mainProgram is tma.
To pull the package into your own flake, use the overlay:
{
inputs.tma.url = "github:pperanich/tmux-agents";
# wherever you build your nixpkgs instance:
nixpkgs.overlays = [ inputs.tma.overlays.default ]; # then: pkgs.tma
}
The hook wrapper
tma install-hooks writes a small tma-hook wrapper next to the tma binary.
That directory is the read-only store here, so the Nix package installs the
wrapper itself: install-hooks finds its own script already in place and writes
nothing. Nothing to set, and tma-hook comes onto your PATH with the binary,
which is all the default [install] wrapper_ref = "bare" needs: agent configs get
the name tma-hook and resolve it off $PATH at hook time. tma doctor shows
the reference and the file behind it:
wrapper: tma-hook ✓ on $PATH (/nix/store/<hash>-tma-<version>/bin/tma-hook)
wrapper_ref = "absolute" is where the store needs care, and tma takes it. What
lands in the agent config is then a path outside the store: your profile’s
~/.nix-profile/bin/tma-hook, or /etc/profiles/per-user/<user>/bin/tma-hook
under Home Manager. tma will not write the store path itself, because that path
names one build and is deleted when the build is collected, which would break
your hooks at the next nix flake update rather than at anything you did. It
finds the profile entry by walking $PATH for a tma-hook outside any store
that resolves to the same file, so the substitution only happens when the two are
provably the same install:
wrapper: /etc/profiles/per-user/you/bin/tma-hook ✓ (/nix/store/<hash>-tma-<version>/bin/tma-hook)
Running straight from the store with no profile install (nix run, a nix build
result) has no such stable path to find, so tma wires the store path and warns
that it will not survive collection.
--wrapper-path <PATH> (env TMA_WRAPPER_PATH) keeps the wrapper out of the
store entirely by naming where it is written; tma install-hooks --check and
tma doctor resolve it the same way install did, so export the variable rather
than passing the flag once.
With the Home Manager module
{
imports = [ inputs.tma.homeModules.default ];
programs.tma = {
enable = true;
settings.notify.on = [ "blocked" "done" ];
keybindings.enable = true;
};
}
The module’s options:
| option | type | default | effect |
|---|---|---|---|
programs.tma.enable | bool | false | Installs the package and writes the files the options below ask for. |
programs.tma.package | package | pkgs.tma when the overlay is applied, otherwise built from the module’s own source tree | The package to install. |
programs.tma.settings | TOML attrset | {} | Written to $XDG_CONFIG_HOME/tma/config.toml. An empty attrset writes no file at all, leaving tma on its built-in defaults. Keys are the ones in configuration, and an unknown one is a parse error at runtime, not at build time. |
programs.tma.agents | attrset of TOML attrsets | {} | Each entry becomes $XDG_CONFIG_HOME/tma/agents/<name>.toml. A new stem adds an agent; a stem matching a bundled manifest (claude, codex, cursor, gemini, opencode, pi) replaces it wholesale, so that entry has to be a complete manifest. |
programs.tma.keybindings.enable | bool | false | Appends tma’s tmux bindings to programs.tmux.extraConfig. An assertion fails the build unless programs.tmux.enable is also on. |
keybindings.enable is the declarative alternative to running tma install-keys.
Use one or the other: both write the same keys, and running both defines each
binding twice. The module writes the whole prefix set and nothing else; the
opt-in mouse bindings still need tma install-keys --mouse. A test in the binary
reads the module and fails if its block drifts from the bindings install-keys
writes, so the two cannot silently disagree. See
Keybindings for what each key does.
The module installs the binary and writes config. It does not touch your agents’
own config files, so wiring an agent is still
tma install-hooks, which needs nothing set.
Next
- Getting started walks the whole loop from an empty tmux.
- Install agent hooks covers the per-agent wiring.
- Show agents in your status line covers the ambient driver, and Install the keybindings covers the key set.
Install agent hooks
Wire an agent so it reports state through hooks instead of screen detection alone. Every agent below follows the same three commands: install, verify, uninstall. What differs is where the config lives and, for some agents, a one-time trust step you must do inside the agent itself.
tma install-hooks is idempotent and additive: it prints a diff and asks before
writing, preserving unrelated config. Pass --yes to apply without the prompt.
The hook-event wiring points at the tma-hook wrapper, never the binary directly,
so rebuilds never break it.
The statusline context shim (opt-in)
One piece of wiring is not installed by default: the statusline context shim for
Claude and Cursor. It is the only edit tma makes to a value you already own —
your statusLine command — rather than adding tma’s own keys beside it, so you
have to ask for it:
tma install-hooks claude --statusline # wire it
tma install-hooks claude --no-statusline # remove it, restoring what it wrapped
It buys one thing: the context-window gauge (@agent_tokens), which the compact
action gates on. That metric appears in no hook payload — the statusline payload
is the only place an agent reports it. Skip the shim and everything else works
unchanged, because state, jumps and notifications all come from the hook events.
Because an agent’s statusLine takes exactly one command, the shim composes
rather than replaces: it reads the payload once, forwards a copy to tma event --kind context in the background, and pipes the same bytes to the command you
already had, whose output is still what gets rendered. It cannot go through the
tma-hook wrapper for that reason, so it embeds the resolved tma path with a
$PATH lookup behind it: [ -x "$_TMA_BIN" ] || _TMA_BIN=tma. Move the binary
without a $PATH entry and the context gauge stops while your statusline keeps
working, which is the failure this shape is chosen for.
If you would rather own the composition yourself, point statusLine at a script
of your own that calls tma-hook <agent> context alongside your real statusline,
and leave the shim uninstalled — tma-hook is generic over the event name, and
tma event falls back to $TMUX_PANE from the environment.
You state the choice once. --statusline records the agent in
statusline-state.toml in tma’s config dir, so a later plain install-hooks
keeps the shim current (re-pointing it at a moved binary) instead of reporting it,
and --no-statusline clears the record along with the shim.
--check reads the same record: with no flag it passes for an agent that opted in
and for one with no shim, and reports only a shim nobody asked for — which is what
an install from before this release looks like. --check --statusline requires the
shim regardless; --check --no-statusline requires its absence.
For which states each agent’s hooks cover and why, see
Agent coverage. For every flag and path
override, see tma install-hooks.
Claude Code
Config: ~/.claude/settings.json (hooks block).
tma install-hooks claude
tma install-hooks claude --check
tma install-hooks claude --uninstall
--check reports tma: hooks OK when the wiring is complete. No trust step:
Claude loads the hooks on next start.
Answer Claude’s prompts over the hook lane
Claude’s PermissionRequest hook can hand back a decision, not just a stamp. With
the lane on, tma act approve on a blocked claude pane returns a structured allow
to the hook that is holding the call open, and the tool runs with no keystroke
landing anywhere. Installing claude’s hooks is all it takes: the lane is on by
default, at a 25-second hold.
Turn it off by setting the key to false:
[hooks]
claude_reply_lane = false
Or set your own hold with the table form, { hold_ms = 12000 }: anything from 1000
to 590000 ms, since the ceiling has to stay under claude’s own hook timeout (see
[hooks]).
Nothing gets reinstalled either way. The hook reads this on every fire, so the next
permission prompt is already on the setting you just wrote.
What the hook does with it on: the same stamps as before (blocked / permission
and the pending-call trio), then it mints the request’s id, stamps it on
@agent_permission_request, writes a 0600 record of the pending call under
$XDG_RUNTIME_DIR/tma/requests/, and holds. A verdict written inside the hold goes
back to claude as its own decision object and the hook exits 0. A hold that expires
deletes its record, prints nothing, and exits 0, which hands the prompt straight
back to claude.
Nothing is hidden and the keyboard never stops working. Claude draws its dialog
the moment it asks and does not wait for the hook, so the hold changes nothing about
what is on the screen. Screen detection still reads blocked, tma act approve --pane still sends 1 when no hook is holding, and typing at the pane answers it
the way it always did: on Claude Code 2.1.261 a 1 pressed half a second into a hold
resolved the call in 50 ms with the hook still parked. A pane nobody answers over the
lane behaves exactly like a pane with the lane switched off. That is the guarantee,
not a fallback: the lane can only add a way to answer, never take one away.
What being on by default costs is one sleeping tma event process per permission
prompt you answer by hand, until its hold expires. If that is not a trade you want
on a given machine, claude_reply_lane = false is the switch.
Answering one
Find the blocked pane and the request it is parked on, then answer that exact request:
tma ls --json | jq -r '.agents[] | select(.state=="blocked") | "\(.pane) \(.permission_request)"'
tma act approve --pane %7 --expect-permission-request 6f1c2a09d4b7e310
Exit 0 means the hook took the verdict: outcome replied, and kind reads hook
in the act audit log. Exit 4 with
request-gone means the prompt turned over between the read and the dispatch, so
the id the pane carries is no longer the one you quoted and nothing was sent. Adding
--dry-run to that same command says which arm it would take right now, naming the
held request when a hook is parked on one and the key sequence when none is.
v1 covers claude and no other agent. OpenCode answers its own prompts over HTTP instead (the API lane), and every other agent still gets keystrokes.
OpenCode
Config: a JS plugin in ~/.config/opencode/plugin/tma.js.
tma install-hooks opencode
tma install-hooks opencode --check
tma install-hooks opencode --uninstall
The plugin forwards OpenCode’s event-bus events to tma-hook. There is no
session-end event, so deregistration rides pane close rather than a hook; nothing
you need to configure.
Codex CLI
Config: two channels, both written at once: notify in
$CODEX_HOME/config.toml and a Claude-style $CODEX_HOME/hooks.json (default
~/.codex/).
tma install-hooks codex
tma install-hooks codex --check
tma install-hooks codex --uninstall
Caveat: the installer prints a trust step, and it is load-bearing:
tma: codex trust gate: the hooks.json entries stay INERT until you open codex, run /hooks, and trust the tma-hook entries (codex silently skips untrusted hooks). Codex pins that trust to the exact command string, so an install that CHANGES it (an [install] wrapper_ref switch, a moved wrapper) has to be trusted again. The notify signal works without this step.
tma: installed hooks for codex
Codex silently skips any untrusted hook, so after installing you must open codex,
run /hooks, and trust the tma entries before the hooks.json events fire. Trust
is recorded against the hook’s exact definition (a trusted_hash per entry in
~/.codex/config.toml), so any change to the command string means re-trusting:
moving the wrapper, and also switching [install] wrapper_ref between bare and
absolute. That is the one real cost of migrating an old absolute install to the
bare default, and it applies to codex’s hooks.json only. The notify channel
is a plain config value and is not trust-gated, so idle detection works
immediately.
Codex allows exactly one notify program, so a tool that wants the signal
(Codex Computer Use, for one) takes the key and passes what was there before to
itself, as a JSON array in its own --previous-notify argument. tma reads that:
a chain carrying tma-hook codex notify is wired, and --check and tma doctor
report notify chained through <program> instead of a missing entry. Install
leaves a working chain untouched. If the chained command names a wrapper this
build no longer writes, install refuses and names the edit rather than rewriting
another program’s argv; fix the reference in config.toml by hand.
Gemini CLI
Config: ~/.gemini/settings.json (hooks object, same shape as Claude’s).
tma install-hooks gemini
tma install-hooks gemini --check
tma install-hooks gemini --uninstall
Caveat: Gemini gates local config behind a per-folder trust prompt:
tma: gemini folder-trust gate: the settings.json hooks load only after you trust the working folder in gemini (it prompts "Trusting a folder allows Gemini CLI to load its local configurations, including … hooks …" on first run there). Once the folder is trusted the hooks fire; there is no separate per-hook trust step.
tma: installed hooks for gemini
Trust the working folder when Gemini prompts on first run there; after that the hooks fire with no further step.
Cursor CLI
Config: two files. ~/.cursor/hooks.json carries the hooks (cursor’s own shape,
not the Claude shape); ~/.cursor/cli-config.json carries the statusLine
context shim. One command writes both, and --uninstall removes both.
tma install-hooks cursor
tma install-hooks cursor --check
tma install-hooks cursor --uninstall
Each file is parsed and rewritten on its own, so unrelated keys in either survive.
An absent cli-config.json is created on install and never created by uninstall.
Override the paths with --cursor-hooks / TMA_CURSOR_HOOKS and
--cursor-cli-config / TMA_CURSOR_CLI_CONFIG.
Caveat: the hooks are user-level. Cursor fires hooks only from
~/.cursor/hooks.json, not a project-level .cursor/hooks.json, so the wiring is
global to your user rather than per-repository. tma writes cursor’s schema
({"version": 1, "hooks": {"<event>": [{"command": "…"}]}}) and preserves any
unrelated hooks already there. Cursor exposes no permission hook, so blocked is
detected from the screen rather than a hook.
pi
Config: a self-contained JS extension at
~/.pi/agent/extensions/tma.js (default; $PI_CODING_AGENT_DIR/extensions/ if
set).
tma install-hooks pi
tma install-hooks pi --check
tma install-hooks pi --uninstall
Caveat: pi has no JSON hook block. It auto-discovers extension modules from
~/.pi/agent/extensions/, so tma drops a tma.js file there that subscribes to
pi’s events and shells out to tma-hook fire-and-forget. The extension is inert
outside tmux and never blocks pi. pi auto-runs tools with no approval prompt, so
there is no blocked state for it at all.
Verifying everything at once
A bare --check inspects every known agent plus the shared wrapper and tmux
hooks, and its exit code reflects drift (0 = wired, 1 = incomplete):
$ tma install-hooks --check
tma: hooks OK
If something is missing it names it, for example after an uninstall removed the tmux server hooks:
$ tma install-hooks --check
tma: hook wiring incomplete:
- tmux hook after-select-pane missing (config reload?)
- tmux hook session-window-changed missing (config reload?)
run `tma install-hooks <agent>` to reinstall
The tmux hooks are runtime server state, so a kill-server or a reboot drops them
even though the agent config is untouched. --check calls that case out
separately (“installed but not present on this server, likely restarted”); see
making the hooks survive a restart.
The attention-clear tmux hooks
tma install-hooks <agent> also installs two tmux server hooks so a pane’s
attention flag clears the moment you look at it, and again when you look away.
You do not add these yourself; they are shown here so you recognize them in
show-hooks:
$ tmux show-hooks -g | grep clear-attention
after-select-pane[0] run-shell "if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=after-select-pane '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=after-select-pane tma clear-attention '#{pane_id}' 2>/dev/null || true; fi"
session-window-changed[0] run-shell "if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=session-window-changed '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=session-window-changed tma clear-attention '#{pane_id}' 2>/dev/null || true; fi"
session-window-changed is the window half rather than the obvious
after-select-window, because tmux runs that one even when you select the window
you are already in — and the “window you left” it reports there is whatever
window you left however long ago, so the clear landed on a pane you had not
looked at since. tma removes any after-select-window entry of its own when you
re-run tma install-hooks. If you wired one by hand from an older version of
this page, check tmux show-hooks -g after-select-window and remove tma’s line.
The command names the binary tma was installed from and falls back to whatever
tma is on $PATH when that path is gone, so a rebuild or a move does not leave
a dead hook behind. tma install-hooks --check compares each installed hook
against the command this build would write and reports a mismatch as stale; the
next tma install-hooks <agent> rewrites it in place.
On every pane switch and every REAL window change, tma clear-attention drops
@agent_attention on two panes: the one you just moved to, and the one you just
left. So the done/blocked flag reverts to plain idle as soon as you jump to (or
manually switch to) that pane — and also when an agent finishes while you are
sitting there watching it and you then move on, which is the case an arrival-only
clear left marked for as long as you stayed away.
Clearing on departure does not touch the walk-away signal, and not by a threshold: leaving an agent running and going to lunch means you never navigate, so no hook fires and nothing clears, and navigation that moves nothing clears nothing anywhere. A pane switch in some other window clears only that window’s departed pane; every other flag stands.
Departure means a pane or a window, never a whole session: switch-client to
another session leaves the mark standing on the pane you were watching. The mark
comes down when you return: your first keystroke in that pane, or your next pane
or window switch inside that session.
That limit is a choice, and there are two hooks you could reach for if you wanted
to change it yourself. Neither is safe, in different ways.
client-session-changed fires the same for switch-client -t <the session you are already on> as for a real switch, and the session it names as the one you left is
stale on that no-op, so a departure clear there drops done marks in sessions you
had not been near. pane-focus-out has no such staleness — it fires on a real
session change and on none of the no-ops, naming the departed pane directly — but
it also fires when you cleanly detach, when any menu or popup opens over the pane
(including the prefix-a picker), and, with focus-events on, on every pane and
window switch as well. And it does not fire at all while another client is still
attached to the session you left — which, if you run the tma daemon, is always,
because the daemon parks a control-mode client on every session it watches. (It
also does not exist below tmux 3.3 unless focus-events is on.) tma installs
neither hook; if you wire one by hand, that is the behaviour you are choosing.
The hooks are not the only clear. If you never navigate at all — the agent
finishes under your eyes and you just keep typing at it — the poll cycle takes
the mark down on your next real terminal input, provided a client of yours is
displaying that pane and that input lands after the mark went up. Input means
anything your terminal genuinely sends, which with focus-events on includes the
focus report you generate by switching to another application. That needs no hook and
no install; it is the same walk-away rule read the other way round, and it is
what makes the invariant the done mark survives until your next input while that
pane is on screen, or until you navigate off it. Under a control-mode client
(iTerm2’s -CC) tmux freezes the client’s input clock at attach, so there the
hooks are the only clear you get.
TMA_HOOK_KIND in the command is how clear-attention knows which of the two
hooks fired, which is what tells it where the departed pane is: still in this
window, or back in the window you left. It is an environment variable rather than
an argument on purpose, so that a hook string written by a newer tma still works
against an older binary on $PATH — the older one does not recognize it, ignores
it, and clears the arrival pane as it always did.
An existing install keeps the old behaviour until you re-run
tma install-hooks <agent>. The hooks live in tmux server state, not in tma, so
upgrading the binary does not rewrite them; tma install-hooks --check reports the
old command as stale, and the next install rewrites it in place.
If your tmux has focus-events on, you can also clear attention on terminal focus
changes (switching into the tmux window from another app) by opting in:
[focus]
events = true
This installs an additional pane-focus-in hook. It is off by default because it
requires focus-events on to be set in tmux. It carries no TMA_HOOK_KIND: it
also fires when the client regains focus, which is not a departure from
anything, so it clears the arrival pane only.
Making the tmux hooks survive a server restart
set-hook writes runtime server state. A kill-server, a reboot, or the last
client detaching from a server started with exit-empty on takes the hooks with
it, and nothing reinstalls them: tma install-hooks is the only writer, and the
next tma command does not re-run it. tma install-hooks --check and tma doctor
name that state on its own (“installed but not present on this server, likely
restarted”) so it is not confused with never having installed them.
To make them durable, put the same commands in your tmux config, substituting your
own tma path:
set-hook -ga after-select-pane "run-shell \"if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=after-select-pane '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=after-select-pane tma clear-attention '#{pane_id}' 2>/dev/null || true; fi\""
set-hook -ga session-window-changed "run-shell \"if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=session-window-changed '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=session-window-changed tma clear-attention '#{pane_id}' 2>/dev/null || true; fi\""
Two things matter here:
- Use
-ga, not-g. An unindexedset-hook -greplaces the whole hook array, so it deletes tma’s entry (and anyone else’s) on everysource-file, the same hazard--checkreports as a wiped hook. - Keep the command byte-identical to what tma installs. Copy it out of
tmux show-hooks -g | grep clear-attentionrather than retyping it:--checkcompares against the command this build writes, so a hand-shortened variant is reported as stale, and re-running install rewrites the runtime copy while your conf line puts the old one back at the next restart.
Alternatively, skip the conf entirely and re-run tma install-hooks <agent> after
a server restart; tma doctor tells you when that is needed.
What the last uninstall cleans up
Uninstalling the last wired agent also clears tma’s @agent_* pane options from
every pane on the server. Nothing refreshes them once the wiring is gone, so a
#{@agent_state} left in a border or status format would otherwise show one
frozen state forever.
One thing it does not touch: a status-line entry you added yourself, such as
set -g status-right '#(tma status)'
tma never wrote that line, so it never edits it; the uninstall prints a reminder and leaves your config alone.
Sharing one agent config between machines
By default every wiring names the wrapper by its bare name:
"command": "tma-hook claude Stop"
Every machine resolves that off its own $PATH, so a ~/.claude/settings.json
synced between a Mac and a Linux box works on both. Nothing to configure.
If your configs were written by a tma older than 0.6 they carry absolute paths instead:
"command": "/Users/you/.local/bin/tma-hook claude Stop"
That path is correct on the machine that wrote it and wrong on any other. It keeps
working where it was written, and --check does not report it as drift as long as
it resolves to the wrapper, so there is no hurry. Repoint everything already wired
when you want the portable form:
tma install-hooks --all
--wrapper-ref absolute writes paths again for a single run, and [install] wrapper_ref = "absolute" makes that the standing choice.
One caveat if you use codex: it pins its hooks.json trust to the exact command
string, so entries rewritten by --all are inert until you open codex, run
/hooks, and trust them again. Codex’s notify channel and every other agent are
unaffected.
--all covers the agents that already carry tma wiring, which is a different set
from the one tma init wires: init only touches agents whose launcher it finds on
$PATH, so an agent you have since removed from $PATH keeps its old wiring
through a re-run of init but is repointed by --all.
$HOME is deliberately not an option here. Half the wiring never reaches a
shell: Codex’s notify is an argv array, and the OpenCode plugin and pi extension
call spawn() directly, so $HOME/.local/bin/tma-hook would be taken as a
literal filename with a dollar sign in it. A bare name works everywhere because
execvp searches $PATH the same way a shell does.
What a bare name gives up is the guarantee that the wrapper is findable. A
GUI-launched editor often inherits a narrower $PATH than your shell, and a
wrapper an agent cannot find fails silently by design. Two things guard that:
install-hooks refuses to wire anything when tma-hook is not on the $PATH it
can see, and tma doctor reports the reference rather than the file:
wrapper: tma-hook ✓ on $PATH (/home/you/.local/bin/tma-hook)
A ✗ not on $PATH there means the wiring is intact and inert. Put the wrapper’s
directory on the agent’s $PATH, or set wrapper_ref = "absolute" and
re-install.
Agents that run in a container
Install the hooks where the agent’s config lives, which is inside the container, and give it the tmux socket plus the pane id so its events reach the host server. The full recipe, including the one identity carve-out that will otherwise wipe the pane’s state, is Run an agent in a container.
Overriding paths
For a non-default config location (test isolation, an XDG-relocated home), every
path has a flag and a matching environment variable, listed under
tma install-hooks. For example
--codex-hooks <path> / TMA_CODEX_HOOKS, --gemini-settings <path> /
TMA_GEMINI_SETTINGS, --pi-extension <path> / TMA_PI_EXTENSION.
Two more variables are read by the installed tma-hook wrapper itself, at fire
time rather than at install time, so setting either changes what an already-wired
agent does:
| variable | effect |
|---|---|
TMA_BIN | The tma binary to run. Taken only when it is set and executable; otherwise the wrapper falls back to a tma sitting next to itself, then to $PATH. That resolution happens on every fire, which is why a rebuild or a move never surfaces to the agent as a hook failure. |
TMA_HOOK_SOCKET | Pin the tmux server by name, as tmux -L <name> does. Unset, the wrapper passes no socket flag and tma uses the $TMUX the pane inherited, which is what you want for a normal install. It exists for the test suite and for setups running more than one server. |
Neither is written by install-hooks; export them in the environment the agent
starts in.
Add a custom agent
Teach tma an agent it does not ship a mapping for. This is a data-only
extension: you write one TOML manifest, drop it in ~/.config/tma/agents/, and
wire your agent’s hook to tma. No code change, no rebuild.
A manifest is the complete description of an agent: how to recognize its pane, how its hook events map to states, and (optionally) how to read its screen. This guide builds a minimal hook-only manifest end to end. For the full field reference, see Manifest schema.
1. Write the manifest
Create ~/.config/tma/agents/myagent.toml. The floor is an [identity] block, a
[hooks] block mapping your agent’s event names to state claims, and a
[capture] block (present, may be empty):
min_engine_version = "0.1"
[identity]
process_names = ["myagent"]
[hooks]
covers = ["working", "idle", "lifecycle"]
[[hooks.map]]
event = "Boot"
claim = { lifecycle = "start" }
[[hooks.map]]
event = "Run"
claim = { state = "working" }
[[hooks.map]]
event = "Wait"
claim = { state = "blocked", detail = "permission" }
[[hooks.map]]
event = "Done"
claim = { state = "idle" }
turn_end = true
[capture]
[identity].process_nameslists the#{pane_current_command}basenames that flag a candidate pane. If your agent runs under a generic launcher (many run asnode), addtitle_patternsto narrow the match by pane title, or rely on the hook registration below, which marks the pane regardless of process name.[hooks].coversdeclares which states your hooks report. The engine uses it to know what a screen-capture fallback would still need to watch for.- Each
[[hooks.map]]maps one event to a claim: a state claim ({ state = "working" }, optionally with adetail) or a lifecycle claim ({ lifecycle = "start" }/{ lifecycle = "end" }). State routing is fixed: a manifest maps intoidle/working/blocked/unknown, it cannot invent a state. - Mark your agent’s turn-end event
turn_end = true, and only that one. It is what raises the done mark on a completion tma had no other way to see — a turn that ends without the pane ever having been observed working draws no state edge at all. An event that merely reports the agent is idle (a nag notification) must leave it off, or the mark would come back every time it fired. [capture]is required even when empty; with no[[rules]], detection is hook-only.
The file stem is the agent name. A stem that matches a bundled agent (claude)
shadows it; a new stem adds a new agent.
2. Wire your agent’s hook to tma
Point your agent’s hook at the tma-hook wrapper, passing the agent name and the
event name, with the hook payload on stdin:
tma-hook myagent Boot # on session start
tma-hook myagent Run # when a turn starts
tma-hook myagent Wait # when it needs approval
tma-hook <agent> <event> is the stable contract. The wrapper forwards the stdin
payload to the internal tma event --agent <agent> --kind <event> and resolves
the binary at fire time. If your agent delivers the payload as a trailing argument
instead of on stdin (some notify-style programs do), pass it as a third argument;
the wrapper feeds it to tma on stdin either way. Install the wrapper with any
tma install-hooks <bundled-agent>, or point at it directly.
The event name in the hook must match the event field in your manifest. tma
resolves the agent against the loaded manifest set and applies that manifest’s
map; an event with no matching entry, or an agent with no manifest, is a clean
no-op (exit 0, nothing stamped).
3. Verify
Start your agent so its Boot-equivalent hook fires and registers the pane, drive
a turn, then list agents. The states come straight from your manifest’s map:
$ tma ls
%5 myagent blocked permission 1785114189508 ma2:0.0 myproj 1
%4 myagent working 1785114163134 ma:0.0 myproj
To see the raw stamp your hook wrote, read the pane’s options directly:
$ tmux show-options -p -t %5 | grep '^@agent_'
@agent_attention 1
@agent_detail permission
@agent_evidence_at 1785114189508
@agent_name myagent
@agent_pid 0
@agent_session fe9a1234-0000-4000-8000-0000000000bb
@agent_since 1785114189508
@agent_source hook
@agent_stamped_at 1785114189508
@agent_state blocked
@agent_source hook and the recorded @agent_session confirm the hook path
registered and mapped the pane. For the full trace, tma debug explain <pane>
prints the identity result, every matched and failed rule, and the final verdict;
tma doctor reports the pane’s effective tier and whether its hooks are wired.
If your manifest never seems to load, run tma doctor: a file that fails to parse
is skipped rather than failing the whole set, and doctor’s agents: line names it
with the error.
$ tma doctor
agents: 6 loaded, 1 skipped:
- ~/.config/tma/agents/myagent.toml: TOML parse error at line 4, column 1
Testing without a live agent
To exercise a manifest in isolation before wiring the real agent, load only your
manifest directory and fire an event by hand with $TMUX_PANE set to a target
pane:
tma --manifest-dir ~/.config/tma/agents event --agent myagent --kind Boot --payload -
--manifest-dir loads exactly that directory as a closed set, so a typo in the
manifest surfaces immediately rather than being masked by the bundled corpus. This
is how tma’s own custom-agent integration test drives a brand-new agent name end
to end.
Run an agent in a container
tmux on the host, the agent in a devcontainer. tma runs on the host and knows nothing about containers, yet the pane still shows state, because the agent reports it rather than being inspected.
The plumbing
1. Put tma and a tmux client in the image. tma shells out to tmux for
every read and write, so the binary has to be there too. Match the host’s tmux
major version: tmux refuses a client whose protocol version differs from the
server’s, and the failure is total, not degraded.
RUN apt-get update && apt-get install -y tmux \
&& curl -fsSL <tma release url> -o /usr/local/bin/tma \
&& chmod +x /usr/local/bin/tma
2. Bind-mount the tmux socket when the container is created. Mounts are a
create-time thing, so this goes in your docker run or devcontainer.json, not
in the exec. Ask tmux for the path rather than guessing it: it varies by
platform and with $TMUX_TMPDIR.
sock=$(tmux display -p '#{socket_path}')
docker run -d --name dev \
--user "$(id -u):$(id -g)" \
-v "$sock:/tmp/tmux.sock" \
myimage sleep infinity
In a devcontainer, export the path first (export TMUX_SOCK=$(tmux display -p '#{socket_path}')) and mount it from the environment:
"mounts": ["source=${localEnv:TMUX_SOCK},target=/tmp/tmux.sock,type=bind"]
3. Hand over the socket and the pane at exec time, as environment:
docker exec -it \
-e TMA_SOCKET_PATH=/tmp/tmux.sock \
-e TMUX_PANE="$TMUX_PANE" \
dev claude
Three things are load-bearing here:
TMA_SOCKET_PATHis how tma inside the container finds the server. PassingTMUXthrough instead also works, but only if the socket is mounted at exactly its host path: the variable carries an absolute path that must resolve in the container too. The explicit variable is the version that does not care where you mount it.TMUX_PANEis the pane whose options get stamped. Everytma eventin the container writes to that id and nothing else, so pass the pane the agent is actually running in.--user "$(id -u):$(id -g)"on the container matters because the socket lives in a directory tmux creates0700and owns. Bind-mounting it does not change who owns it, so the container process needs your uid to open it.
4. Install the hooks inside the container, where the agent reads its config:
docker exec dev tma install-hooks claude
The hooks reference the tma-hook wrapper, which resolves tma at fire time and
exits silently if it is missing, so a container without tma is quiet, not broken.
Run tma install-hooks on the host as well if you want the attention-clear tmux
server hooks: those are set on the tmux server, and a container invocation
installs them only if you also give it the socket env from steps 2 and 3.
Check the wiring
The pane is invisible until the first hook fires. With step 4 skipped nothing
ever registers and the pane has no row at all; once a hook has fired, tma debug explain shows the arrangement:
$ tma debug explain %5
pane %5 (dev:0.0)
command docker
agent claude (pid 0, foreground_is_agent=false, registered behind remote shell docker)
boundary remote shell docker — the cycle holds this pane's stamps and captures nothing; hook events are its only evidence
prior working / - src=hook evidence_at=… since=…
tma doctor shows the same pane at tier 2, or tier 3 with a daemon, with its
hooks wired.
What you get, and what you do not
You get everything the hook tier carries: state and transitions, @agent_since,
attention, notifications, tma wait, the picker, tma watch, and actions that
send keys (tmux delivers those to the pane’s tty, which the container process is
reading, so the boundary does not matter). Context telemetry rides the same
route: the statusline shim runs inside the container and pushes to tma event --agent <agent> --kind context --pane "$TMUX_PANE" --payload - over the same
socket, so the gauge crosses the boundary with the events.
You do not get the tiers that inspect the process: no screen-rule fallback when a hook is missed, and no process-walk identity. Practically that means a hook the agent never fires stays unreported, where a host-run agent would have had its state read off the screen. Doctor’s report looks clean, because nothing is misconfigured; the missing capability is a property of the arrangement.
Why it works at all
tma’s other tiers cannot cross a container boundary. The process walk reads the
host’s ps, where the agent’s process does not exist; the screen fold needs the
pane’s foreground command to be the agent, and it is the container client.
The hook tier does not inspect anything. tma event is a stateless one-shot: it
takes a pane id, maps one event through a manifest, writes tmux pane options, and
exits. It keeps no memory between runs, holds no connection, and needs no daemon,
because all the state is in the options on the host’s tmux server. So an agent
inside a container can stamp a host pane directly, provided it can reach the
socket and knows the pane id. Both are things you hand it when you start it.
The one part that is not obvious is why the pane stays in scope. A pane whose
foreground command is docker, podman, kubectl, ssh, or mosh is
classified as a remote shell and taken out of scope, precisely because the process
walk on such a pane comes back empty while the screen sits there inviting a false
match. Registration is the exception: @agent_session on the pane names the
agent that owns it, and a hook could only have set that from inside. The
registration outranks the classification, so the pane keeps its stamps and its
row, and the poll cycle stops capturing it, since nothing readable crosses the
boundary anyway.
The container client in the pane’s process tree is what keeps the stamp alive afterwards. A pid-less registration whose pane holds nothing but a shell is reaped after 30 seconds, which is how a container that exits leaves a clean pane instead of a frozen row.
Run tma over ssh
Two arrangements get called “tma over ssh” and they are not the same problem. Work out which one you have first, because only the second needs anything from this page.
tmux on the far side
You ssh to a host, start tmux there, and run agents in its panes. tma belongs on that host too, beside the agents: install it there and everything works exactly as it does locally, because nothing is remote from tmux’s point of view. Your terminal is a client and tma never sees it.
What a fresh connection does want is a way in that lands on the right pane.
tma jump is switch-client, so it moves a client that is already attached and
does nothing from a terminal that has none. tma attach is the other half: tma attach --pane %5
selects that pane’s window and pane, then replaces itself with tmux attach-session, so one command over ssh (or from a phone’s terminal app) puts you
in front of the agent instead of wherever the session was last left. Inside tmux
it is jump --pane, so the same command works from both ends.
The only thing your connection changes is where a notification lands. osascript
and notify-send fire on the host running tmux, which is not the machine you are
sitting at, and neither fails loudly. Notifying from a remote
host covers the three sinks that
do cross a connection.
ssh from a local pane
tmux runs on your machine, one of its panes runs ssh, and the agent lives on
the far side. tma classifies that pane as remote and takes it out of scope,
along with mosh, docker, podman, and kubectl. The reason is that neither
of the two inspecting tiers survives the hop: the process walk reads your local
ps, where the agent does not exist, and a capture would match screen rules
against output the agent merely painted through a terminal it does not control.
An empty walk plus a plausible-looking screen is how false positives are made.
Concretely, on such a pane:
- No identity from the process tree, and no screen rules.
- Hook events are its only possible evidence.
- Any
@agent_*options it still carries from before the hop are held, not refreshed. Nothing is updating them.
tma doctor reports it, and does not call it a problem, because running an agent
elsewhere is a choice:
remote: 1 pane(s) behind a remote shell — an agent there reports only if it can reach this tmux socket (see docs/how-to/agents-in-containers.md)
- %10 work:4.0 (ssh)
See Remote and ignored panes for the rest of that report.
Make a remote agent report
The hook tier inspects nothing, which is why it is the one tier that can cross.
tma event takes a pane id, maps one event through a manifest, writes tmux pane
options, and exits; it keeps no state between runs and needs no daemon. So an
agent on the far side can stamp a pane on your tmux server, provided it can reach
the socket and knows the pane id.
That is the same shared-socket arrangement Run an agent in a container spells out step by step. Read it for the details; over ssh only the transport differs. Instead of a bind mount, OpenSSH forwards a unix socket the other way:
ssh -o StreamLocalBindUnlink=yes \
-R /tmp/tma-tmux.sock:"$(tmux display -p '#{socket_path}')" buildbox
Pick a remote path you can write, and keep StreamLocalBindUnlink=yes: without
it a socket left behind by an earlier session makes the forward fail silently
while the shell comes up fine.
The rest carries over unchanged. tma and a tmux client whose major version
matches your server go on the remote host; the agent’s environment there gets
TMA_SOCKET_PATH pointing at the forwarded path and TMUX_PANE set to the local
pane id it should stamp; and tma install-hooks <agent> runs on the remote host,
where the agent reads its config.
Weigh this before you wire it. A forwarded tmux socket is your tmux server, reachable from the remote host. Anything running there that can open it can drive every pane on your machine, not only the one you meant. tma adds no check of its own: the security model is your user account, and forwarding widens what counts as your user account. Do it for hosts you control.
What you get is the hook tier and nothing else: state, transitions, attention,
notifications, tma wait, the picker, tma watch. What you do not get is the
fallback, so an event the agent never fires stays unreported, where a local agent
would have had its state read off the screen.
There is no cross-host view
tma has no aggregation across machines. One invocation talks to one tmux server,
chosen by --socket-name or --socket-path, and there is no command that merges
several. Agents on three boxes, each with its own tmux server, means running tma
on each box.
What exists instead is enough provenance to merge the output yourself. Every
tma ls --json row carries host and server, so (host, server, pane) keys a
combined set where a bare %5 would collide; see server and
host.
Collecting those rows, and deciding what to do with them, is yours to build.
Next
- Run an agent in a container for the shared-socket pattern in full.
- Diagnose with
tma doctorto see how a pane was classified. - Set up notifications for the sinks that reach you rather than the host.
Serve tma over ssh
Give one device read access to your fleet, and the ability to answer a prompt,
over an ssh connection you already trust. This is not “run tma over ssh”, which
is about a pane that happens to hold an ssh client; that is Run tma over
ssh. This page is about tma serve, the process that
answers a remote client on the other end of two pipes.
Nothing here opens a port. tma binds no socket, ships no TLS, and holds no
bearer token: sshd authenticates the caller and starts one tma serve per
connection as a forced command. What the connection may do afterwards comes from
a record on your machine, not from anything the client says.
What you need
- sshd running on the host, reachable from the device.
- The device’s ssh public key, and its fingerprint.
- tma on
$PATHfor the account the key logs in as.
1. Pair the device
Take the fingerprint of the key the device will connect with:
$ ssh-keygen -lf ~/phone-key.pub
256 SHA256:0Mn3XQvCJ8pQe1lU6q2ZKq7BhX0tYr9WfSdA3nGkLpM phone (ED25519)
The middle field is the id. Record it:
$ tma device pair phone --id SHA256:0Mn3XQvCJ8pQe1lU6q2ZKq7BhX0tYr9WfSdA3nGkLpM
paired SHA256:0Mn3XQvCJ8pQe1lU6q2ZKq7BhX0tYr9WfSdA3nGkLpM as phone
scopes: read, act:answer, act:steer
That writes ~/.config/tma/devices.toml, mode 0600, by atomic rename. It is
the authorization record: what a connection may do is read from it on every
request.
2. Add the forced command
One line in ~/.ssh/authorized_keys, on the host, for that key:
command="tma serve --stdio --device SHA256:0Mn3XQvCJ8pQe1lU6q2ZKq7BhX0tYr9WfSdA3nGkLpM",restrict ssh-ed25519 AAAAC3Nza… phone
Three parts, and each earns its place.
command="…" replaces whatever the client asks to run. A connection with this
key gets a serve process and cannot get a shell, so the key is scoped to tma by
sshd rather than by tma.
--device <fp> is how serve knows which record to read. OpenSSH performs no
token expansion inside command=, so the value cannot be derived at connect
time and has to be written here by hand. It must be the fingerprint of the key
on that same line. Nothing checks that today, and the failure modes are
ordinary bugs rather than attacks: a mistyped line, a hand-repaired merge
conflict, a restored dotfile backup, or a second line carrying the same key with
a different --device (sshd takes the first match).
restrict turns off port forwarding, agent forwarding, X11 and pty allocation.
Keep it. A device needs a pipe, not a terminal.
3. Dial it
The client runs ssh with no command of its own and speaks NDJSON on the pipe:
$ ssh -T -i ~/phone-key you@host
{"schema":1,"id":"1","t":"hello","app":"probe","app_version":"0","device":"SHA256:0Mn3XQvC…"}
{"schema":1,"id":"1","t":"hello","host":"studio","tma_version":"0.5.13","reconcile_interval_ms":2000,"scopes":["read","act:answer","act:steer"]}
Paste the first line, and the second comes back. That is the whole handshake, and it is a usable smoke test: type it by hand once and you know the forced command, the pairing and the fingerprint all line up. The remote wire protocol is the rest of the conversation.
If the id is wrong or the pairing is missing you get one frame and an exit:
{"schema":1,"id":"0","t":"error","code":"scope-denied","message":"this device is not paired with this host; run `tma device pair` on the host"}
What the scopes mean
| 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 |
Widen one from the host and nowhere else:
$ tma device grant phone act:always
phone now holds: read, act:answer, act:steer, act:always
There is no in-app path to a wider scope, no approval prompt an app can raise,
and no protocol frame that asks for one. A device that dispatches beyond its
grants gets a receipt reading refused / scope-denied, and the refusal happens
before the slot is claimed and before anything reaches the pane.
Two classes are unreachable at any scope. An exec action runs a command
rather than answering a pane, and it is the one action kind that sends no
keystrokes and therefore has no freshness check at all. And an action outside the
table above is refused even if you wrote it yourself, compact included: a phone
reaching /compact is the agent’s own command plane, which is exactly what the
scopes exist to withhold.
tma device pair --only pairs a device with read and nothing else, for a
tablet you want to watch with and never answer from.
Revoking
$ tma device revoke phone
revoked phone (SHA256:0Mn3XQvC…)
remove its `command="tma serve --stdio --device SHA256:0Mn3XQvC…"` line from
~/.ssh/authorized_keys too, so the next dial is refused by sshd as well
The record is the authority and the authorized_keys line is the courtesy.
Removing the line stops the next dial and nothing else: it does not touch a
serve process already holding its pipes, and a device that simply never hangs up
would keep receiving fleet rows. So the record is what revocation removes, and
every live serve process re-reads the store per request and per publish. A
revoked device’s next request is refused, its event stream stops, and the process
exits. Removing the line as well is what stops it dialling back.
What it costs the host
Every serve connection runs its own detection cycle: a capture-pane per
agent pane and its guarded stamp writes, at the reconcile interval, per
connection. Two devices plus the daemon is three concurrent detection loops on a
6-to-12 pane fleet.
That is why the connection count is capped. Four by default, refused beyond that
with a typed too-many-connections error rather than accepted and starved:
[serve]
max_connections = 4
reconcile_interval_ms = 2000
One consequence is worth stating plainly rather than leaving to be discovered: a
read-scoped device is not inert on the host. Subscribing runs a cycle, and a
cycle writes pane options. Those writes are server-side guarded so a cycle that
loses a race loses it correctly, but they are not free. What the scope claims is
that the device authorizes nothing on any agent, and that part is exact.
Hardening, if your sshd allows it
The one gap above is that --device <fp> is written by hand and nothing proves
it names the key sshd actually authenticated. The strong fix needs a config
change you may not control:
# /etc/ssh/sshd_config
ExposeAuthInfo yes
With that set, sshd writes the authenticated key into a file named by
$SSH_USER_AUTH, which a wrapper can compare against --device before exec’ing
tma. It is documented here as the hardening option rather than the baseline
precisely because it is not yours to set on a shared host.
Keep the shipped security model in view
while reading that. tma has one boundary and it is your user account: anyone who
can edit authorized_keys can already run tma act. What the scope model adds
is the first distinction tma has ever drawn inside that account, and the
realistic threat to it is a bug in a file you edited, not an attacker.
Set up notifications
Get alerted when an agent needs you, even while you are looking at another
window. tma fires a notification on a state transition you choose. The signal
can be a terminal bell, an external command you supply, or both.
Notifications are configured under [notify] in your config file. For the full
key reference see Configuration.
Choose the triggers
notify.on is the set of transitions that fire. The default is blocked only; add
done to also fire when a working agent finishes (goes idle with unreviewed
output):
[notify]
on = ["blocked", "done"]
blocked covers “an agent is waiting on you”; done covers “an agent finished
while you were elsewhere”. These are the two moments worth interrupting for.
Ring the terminal bell
The simplest signal needs no external tooling. It rings the bell of the pane the agent is in, which most terminals and tmux surface as a visual or audible alert:
[notify]
on = ["blocked", "done"]
bell = true
Post a desktop notification from the terminal
notify.osc writes an OSC 9
notification sequence to the firing pane’s tty, which the terminal emulator turns
into a real desktop notification. Like the bell it travels down the connection, so
it works over ssh, mosh, and tmate: the emulator you are sitting at is what
renders the banner, no matter where tmux runs.
[notify]
on = ["blocked", "done"]
osc = true
It is off by default because emulator support varies (WezTerm, kitty, and iTerm2
handle OSC 9; an emulator that does not understand it ignores the sequence
silently). The text is short and fixed, <agent> <state> — for example claude blocked. It deliberately omits the pane title: a title is written by whatever
runs in the pane, and an escape sequence is a poor place for text tma does not
control.
The tmux status-line message that accompanies every fire goes to every attached client, so both terminals in a pairing setup see it, not just the one that was active most recently.
Let the sequences out of tmux
tmux parses everything a pane prints and forwards only the escape sequences it handles itself: window titles, colours, the clipboard. OSC 9 is not one of them, so tma wraps every sequence on this page in tmux’s own passthrough envelope. That envelope needs one line in your tmux config:
set -g allow-passthrough on
Without it tmux drops the sequence silently: nothing reaches your emulator, and
nothing is printed into the pane either. This applies to osc, osc_777 and
osc_progress alike, and it is the first thing to check when a supported
emulator shows no banner.
The OSC 777 form
Ghostty and WezTerm implement OSC 777 and ignore OSC 9. osc_777 writes that
form beside the OSC 9 one, with the same text split the way 777 wants it: the
agent as the title, the state as the body:
[notify]
on = ["blocked", "done"]
osc = true
osc_777 = true
Turn on the one your emulator reads, or both: they are independent keys because an emulator that understands both would show one banner per sequence. Which is which today:
| sequence | key | emulators |
|---|---|---|
| OSC 9 | osc | iTerm2, kitty, WezTerm |
| OSC 777 | osc_777 | Ghostty, WezTerm |
| OSC 9;4 | osc_progress | Ghostty, WezTerm, Windows Terminal |
Show progress on the tab
A banner is a moment; osc_progress is a state. It writes the OSC 9;4 progress
sequence, which those emulators render on the tab or in the taskbar, so it is
still there after you have switched away or minimized the window:
[notify]
osc_progress = true
It is scoped to a tmux window, not a pane: the indeterminate state goes out
when a window’s rollup gains its first working agent, and the clear when its
last one finishes. Nothing is written in between, so a window full of working
agents costs exactly two sequences for the whole run.
Two things follow from the sequence being a terminal-level state. The indicator belongs to the terminal tab rather than to the tmux window, so with agents working in several tmux windows at once the most recent edge is what the tab shows. And because it persists, the daemon clears it on shutdown and when you turn the key off and reload, rather than leaving a lit tab behind.
This one needs a running daemon: the edge is a comparison against the previous pass, which only a resident process has.
Run a command
For a real notification (desktop banner, phone push), set notify.command. tma
runs it and pipes a JSON object to its stdin describing the transition. The
payload carries metadata only, never captured screen content; its exact key set
(agent, pane, state, detail, session, locator, title, repo,
branch, since_ms, context_pct, plus a schema version) is documented in
Notification hook payload.
repo and branch come from the pane’s working directory, so a message can say
which checkout is waiting on you without your hook shelling out to git.
[notify]
on = ["blocked", "done"]
command = "~/.local/bin/tma-notify"
Example: macOS desktop banner
Save this as ~/.local/bin/tma-notify and chmod +x it. It reads the payload and
posts a native notification with osascript:
#!/bin/sh
# reads tma's notify payload on stdin
payload=$(cat)
agent=$(printf '%s' "$payload" | jq -r '.agent')
state=$(printf '%s' "$payload" | jq -r '.state')
locator=$(printf '%s' "$payload" | jq -r '.locator')
osascript -e "display notification \"$agent is $state\" with title \"tma\" subtitle \"$locator\""
Example: push to your phone with ntfy
The same script shape, pushing to an ntfy topic so a blocked agent buzzes your phone:
#!/bin/sh
payload=$(cat)
agent=$(printf '%s' "$payload" | jq -r '.agent')
state=$(printf '%s' "$payload" | jq -r '.state')
locator=$(printf '%s' "$payload" | jq -r '.locator')
curl -s \
-H "Title: $agent is $state" \
-H "Tags: warning" \
-d "$locator" \
https://ntfy.sh/your-topic-name > /dev/null
Send each trigger somewhere different
notify.command is the fallback for every trigger. Each one can also name its
own command in a [notify.<trigger>] sub-table, which is what you want when the
triggers are not equally urgent: a blocked agent is worth a phone push, a
completion is worth a line in a file.
[notify]
on = ["blocked", "done"]
[notify.blocked]
command = "curl -s -d \"$TMA_AGENT blocked in $TMA_LOCATOR\" https://ntfy.sh/your-topic"
[notify.done]
command = "cat >> ~/.local/state/tma/done.jsonl"
context_high and stall take a command the same way, beside their threshold. A trigger
with no sub-table, or a sub-table that sets no command, falls back to the global
notify.command, so routing one leaves the others alone. An unknown key inside a
sub-table is a parse error rather than a silent fallback.
tma debug notify-test --trigger done runs whichever command that trigger
actually resolves to, which is the quickest way to confirm the routing landed.
When nothing arrives
A fire runs your command in the background and discards its output, so a typo’d path or a script that exits non-zero produces silence rather than an error. Two places surface it:
tma debug notify-test --trigger blocked # run it now, see stderr and the exit code
tma doctor # reports the last failure a real fire hit
notify-test builds a representative payload, runs the command the trigger
resolves to, waits for it, and prints what happened. Whenever a real fire’s
command cannot start or exits non-zero, tma records that one failure; tma doctor
prints it (with the reason and the command), and the next clean fire clears it.
Notify on high context
context_high fires when a pane’s context-window utilization crosses a threshold,
so you learn a session is nearly full without watching a gauge. It is separate
from on: name it as a sub-table with a threshold percent.
[notify]
on = ["blocked"]
context_high = { threshold = 75 }
It fires once on the crossing and then holds: staying high does not re-ring, and
it rearms only after the gauge dips below threshold - 10 (a shallow compact that
lands inside that band leaves it silent, by design). The payload’s state field
carries context_high so your hook can tell it from a blocked or done alert.
The gauge itself comes from a telemetry channel the agent’s manifest declares, so
context_high is silent for an agent with no context coverage. It rides the same
marker and command as the other triggers; the full key reference is in
Configuration.
Notify on a stalled agent
stall fires when a pane has been continuously working for longer than you
expect a turn to take, which is how you find out an agent is wedged on a hung tool
call instead of discovering it an hour later. Like context_high it is separate
from on: name it as a sub-table with a threshold in seconds.
[notify]
on = ["blocked"]
stall = { threshold_s = 900 }
The clock is the pane’s own @agent_since, so the measurement is the current
working run and nothing else; a pane that finishes and starts again begins from
zero. It fires once per run and then holds, however long the run goes on, and
rearms when the pane leaves working. The payload’s state field carries stall
and since_ms is how long the pane had been working when it fired.
Pick the threshold from your own turns: long enough that a normal build or test run does not trip it, short enough that you would rather be told. There is no default, because a good one is per-agent and per-repo.
The daemon is what notices. A stalled pane produces no events by definition, so
the check runs on the poll cycle rather than on a transition, which means a
daemonless setup ([notify] from_event) never fires it.
Silence one pane for a while
Config decides what fires everywhere; tma mute silences a single pane you are
not currently interested in.
tma mute # the current pane, until you clear it
tma mute --for 30m # …for half an hour (45s / 2h / 1d also parse)
tma mute --session build # every agent pane in that session
tma mute --clear --session build
Mute suppresses the fire and nothing else: every sink stays quiet (the
display-message line, bell, osc, the [notify] command, context_high and
stall included) while detection, stamping, and the tma status counts carry on
exactly as before, so the pane still reads blocked in tma ls and in the JSON
(where the row gains "muted": true). Nothing is queued either — a mute that
expires mid-episode does not then ring for the transition it silenced. A detached
action’s completion still reports, since that one you asked for.
The deadline is stored in the pane itself (@agent_mute_until), so it outlives a
tma restart, a daemon stop, and a tma reload; killing the pane is the other
way to end it. Full flag reference: tma mute.
Keep a history
Two records answer two different questions.
What was sent. notify.log appends one JSON line per fired notification:
[notify]
on = ["blocked", "done"]
log = "~/.local/state/tma/notifications.jsonl"
Each line is the hook payload plus an at field with the fire time in epoch
milliseconds; a detached action’s completion is logged the same way, carrying
action and outcome where a state line carries state. It is written by whichever process fired (daemon or hook), the
parent directory is created for you, ~ is expanded, the file is created 0600
and appended to, never rewritten. A log that cannot be written is skipped
silently, since a hook must never fail on its notifications.
The line carries no pane title unless you set include_title = true — it shares
one writer with the payload that goes to your carrier, so the two redact
together.
jq -r 'select(.state=="blocked") | "\(.at) \(.repo)@\(.branch) \(.locator)"' \
~/.local/state/tma/notifications.jsonl
What changed. The daemon keeps a richer in-memory ring of the last 256 state transitions, including the ones that never fired a notification:
tma debug transitions # human-readable, oldest first
tma debug transitions --json # {"schema":1,"cap":256,"recorded":N,"transitions":[...]}
That ring is daemon memory: it needs a running daemon and starts empty after a restart. The log file is the durable one. Use the ring to answer “what did tma observe”, the log to answer “what did it tell me about”.
What you did about it. The third record is the act audit log: one line per
tma act fire, with the surface that asked for it. It is configured separately
([act] log) and follows the same rules as this one, 0600 and append-only, so
the natural place for it is the same directory. See The act audit
log.
Notifying from a remote host
notify.command runs on the machine running tmux. Over ssh that is the remote
box, which is why the usual desktop recipes go quiet: osascript has no
Aqua session to talk to, and notify-send has no D-Bus session bus. Neither
errors in a way you would notice, so it looks like tma stopped firing.
Three things do work across a connection:
-
The bell.
bell = truewrites a BEL to the pane’s tty, which travels down the ssh/mosh/tmate connection like any other output, and your local terminal (or tmux’smonitor-bell) reacts. -
The OSC sinks.
osc = true(andosc_777 = true) do the same with an escape sequence, so a supporting emulator raises a real desktop notification on the machine you are sitting at. The remote side needs onlyallow-passthrough onin the tmux running there. -
A push service. Send the notification out over the network instead of to a desktop. ntfy is the smallest version — the whole payload is on stdin, so a one-liner works:
[notify] on = ["blocked", "done"] command = "curl -s -H \"Title: $TMA_AGENT $TMA_STATE\" -d \"$TMA_REPO $TMA_LOCATOR\" https://ntfy.sh/your-topic-name > /dev/null"Pick an unguessable topic name: an ntfy topic is public to anyone who knows it.
If you are attached to the remote tmux from a local one, remember that the status-line message goes to every attached client, so a second terminal watching the same session sees it too.
Daemonless vs daemon
The command fires from whichever process observes the transition, and by default only a running daemon dispatches notifications (it is the resident process that can watch for a transition and fire it). To fire from a hook directly with no daemon, opt in:
[notify]
from_event = true
on = ["blocked", "done"]
command = "~/.local/bin/tma-notify"
With from_event = true, tma event fires the notification itself as the hook
lands, before exiting. This covers hook-capable agents with no background process.
For hookless agents, and for deduplicated notifications across every detection
path, run the daemon; see run-the-daemon.
Author a custom action
An action is a guarded thing tma does to an agent pane: send a key sequence, or
run a command with the pane’s context in its environment. You declare one in a
TOML file, dry-run it until it looks right, then fire it from any surface. This
guide writes an exec action that asks a live Claude session for a progress
summary; for the full field list see
Action manifest schema.
Drop the manifest
User actions live in ~/.config/tma/actions/. The file stem is the action name,
so summarize.toml is tma act summarize:
# ~/.config/tma/actions/summarize.toml
min_engine_version = "0.1"
name = "summarize"
label = "Summarize progress"
kind = "exec"
agents = ["claude"]
when = { state = ["working", "idle"] }
requires = ["session"]
confirm = true
command = "~/.config/tma/actions/summarize.sh"
agents limits it to Claude panes. when gates it to working or idle.
requires = ["session"] refuses cleanly if the pane never registered an agent
session id, so the script never runs against an empty TMA_SESSION_ID.
Write the command
The command is passed to sh -c verbatim. It learns which pane it serves only
through environment variables, never through the command string, so there is no
quoting injection from a pane title. Fork the session so the user’s live TUI is
untouched:
#!/bin/sh
# ~/.config/tma/actions/summarize.sh (chmod +x)
set -eu
claude -p --resume "$TMA_SESSION_ID" --fork-session \
"Summarize progress and list open questions" \
| tma-notify-or-your-own-sink
The available variables are TMA_PANE, TMA_AGENT, TMA_STATE, TMA_DETAIL,
TMA_SESSION_ID, TMA_CWD, TMA_PID, TMA_LOCATOR, TMA_TITLE, and
TMA_ACTION, plus the caller’s --arg values (below). Quote every one of them: "$TMA_TITLE", not $TMA_TITLE. A title
is text the agent printed, so an unquoted expansion re-parses hostile input. The
command runs in tma’s own working directory, not the pane’s; a script that needs
the agent’s directory uses TMA_CWD explicitly (cd "$TMA_CWD"). The security
model
explains what the env transport does and does not protect.
Dry-run, then fire
--dry-run resolves everything and executes nothing: the context env with each
value’s age, the gate verdict, and the command that would run. It is to actions
what tma debug explain is to detection.
$ tma act summarize --pane %5 --dry-run
action: summarize
pane: %5
agent: claude
gate: fireable
effect: command: ~/.config/tma/actions/summarize.sh
context:
TMA_SESSION_ID 0d1a... (1200 ms old)
TMA_CWD /home/you/proj (live)
Edit, dry-run, fire, with no rebuild. When it looks right, fire it. confirm = true means a non-interactive fire needs --yes:
$ tma act summarize --pane %5 --yes
Fire past the gate: --force
--force skips the when gate, and only the when gate. It is for the case
where you know better than the stamp: the pane reads working because a hook
missed its Stop, and you want to act anyway rather than wait for the next cycle
to correct it.
$ tma act summarize --pane %5 --force --yes
Everything else still holds. requires is still checked, so an action needing a
session id still refuses requires-unmet (exit 4) on a pane that never registered
one. The single-flight lock is still taken, so --force cannot run two copies at
once. Identity still applies: an action declaring agents = ["claude"] still
refuses on a codex pane. --force is not --yes, either; a confirm action needs
both.
One thing --force skips that is easy to miss: an action that reaches the pane
with keystrokes (keys or text) normally re-verifies a stale pane on demand
before gating. Under --force there is no gate to verify for, so no
re-verification happens and the keys go out against whatever the pane looks like
now.
Pass a value in: --arg
Some actions need a payload from the caller, not just the pane’s context. --arg
carries one (repeat it for more), and it travels the same way everything else
does — as environment, never spliced into command:
# ~/.config/tma/actions/queue-next.toml
min_engine_version = "0.1"
name = "queue-next"
label = "Queue the next task"
kind = "exec"
agents = ["claude"]
when = { state = ["idle"] }
requires = ["session"]
confirm = true
command = "~/.config/tma/actions/queue-next.sh"
#!/bin/sh
# ~/.config/tma/actions/queue-next.sh (chmod +x)
set -eu
[ -n "${TMA_ARG:-}" ] || { echo "queue-next needs --arg <task>" >&2; exit 2; }
# The value is data: it reaches the agent as an argument, never as shell source.
tmux send-keys -t "$TMA_PANE" -l -- "$TMA_ARG"
tmux send-keys -t "$TMA_PANE" Enter
The script gets TMA_ARG (the first value), TMA_ARG_1..N and TMA_ARG_COUNT
when several were passed, and nothing at all when none was. A value that contains
$(reboot) stays those nine characters: nothing expands it, because nothing ever
builds a command string out of it. Quote it anyway ("$TMA_ARG"), as you would
TMA_TITLE.
keys actions refuse --arg (exit 2) on purpose. A keys sequence lives in the
manifest, which is what makes it reviewable, so there is nowhere for a value to
go.
To type a caller’s line into a live session, reach for
kind = "text"
before writing a script like the one above. It keeps the wrapping keys, the
agents and the gate in the manifest, delivers the string with send-keys -l --,
and applies the payload rules (no control bytes, no leading / or !) that a
hand-rolled script has to remember for itself. The bundled steer action is one.
Driving it from a wait loop is the orchestrator shape:
#!/bin/sh
set -eu
pane=%5
since=0
while read -r task; do
row=$(tma wait --pane "$pane" --until idle --since "$since" --json --timeout 900) || exit $?
since=$(printf '%s' "$row" | sed 's/.*"episode_ms":\([0-9]*\).*/\1/')
tma act queue-next --pane "$pane" --arg "$task" --yes
done < tasks.txt
--since is what keeps that loop honest: without it the second wait returns on
the same idle episode it just fed. See
Block a script on agent state.
Fire on a whole fleet
--all turns the selector flags into the
target set instead of a uniqueness requirement, firing on each matched pane in
turn. Dry-run it first: with --all, --dry-run prints the resolved targets and
what each verdict would be, which is the blast radius before it happens.
$ tma act summarize --all --repo tmux-agents --dry-run
targets: 3
%1 claude would fire
%4 claude refused: gated
%7 claude refused: locked
$ tma act summarize --all --repo tmux-agents --yes
Each target runs the full broker sequence on its own — its own single-flight
lock, its own gate re-verification at fire time — so one pane’s refusal neither
skips nor weakens the others. The confirmation is asked once for the batch, and
the process exits with the worst target’s code, so && still means “all of them
acted”.
The broker re-verifies the pane is still a Claude pane in a gated state, acquires
a single-flight lock so a double-press cannot run two summaries at once, spawns
the command, and releases the lock. Exit 0 means the child exited 0; a gate
refusal is exit 4, a held lock exit 5. The full table is in
tma act.
Bound the run: timeout_ms
A synchronous exec action runs under a deadline. timeout_ms is it, in
milliseconds, defaulting to 30000. A child still alive at the deadline is killed
and the action ends with outcome timeout and exit 124, the same code
timeout(1) uses, so a caller branches on it the way it already branches on a
tma wait timeout.
timeout_ms = 120000 # two minutes for a slow summary
Set it to what the command genuinely needs. The value also sets the single-flight
lock’s expiry (the deadline plus a few seconds of slack), so an over-generous
timeout on a command that hangs leaves the pane locked for that long against other
fires, and a too-tight one turns a slow success into a 124. detach = true uses
detach_timeout_ms instead; both are in the action manifest
schema.
Long-running actions
An SDK call can take minutes. Set detach = true and the broker returns
immediately (exit 0, outcome spawned) while a tma-owned supervisor holds the
lock, kills the process group at detach_timeout_ms, and fires a completion
notification through your [notify] command when the child exits. A detached
action is fire-and-forget: its exit code says nothing about the child’s outcome,
which arrives on the completion payload instead. Use a synchronous action (the
default) when a script needs to branch on the result.
Retry an approve safely
A script that fires an action over a network has a problem a local one does not:
when the response never arrives, it cannot tell whether the action ran. Firing
again is a guess, and on approve it is the expensive kind.
Give the dispatch an id and tma will run it at most once:
#!/bin/sh
# Approve the prompt on %5, and survive losing the answer.
episode=$(tma ls --json | jq -r '.agents[] | select(.pane == "%5") | .episode_ms')
slot="approve:%5:$episode"
tma act approve --pane %5 --slot "$slot" --device "$(hostname)" --json
Run that line twice and the pane receives the keystroke once. The second run
prints the first run’s receipt with "cached": true, exits with the first run’s
code, and sends nothing. The id is the whole identity, so build it from what
makes this dispatch this dispatch: the action, the pane, and the episode you read
off the row. A new episode is a new prompt, so it earns a new slot.
If the connection died before you saw any answer at all, ask instead of firing:
tma receipts --slot "$slot" --json
An empty result means the dispatch never reached the host, so it is safe to send it. A receipt means it did, and tells you what it ended as.
Two behaviours make this safe to build on. A locked refusal (exit 5) releases
the slot, because a lock held by another invocation is the one refusal a retry
fixes. Everything else writes a terminal receipt, including a broker error,
whose receipt records fired-unknown: tma cannot prove that keystroke did not
land, so it will not send a second one on your behalf. Receipts live for 24 hours
in a per-host ledger, so a retry from a different connection, or a different
device, still lands on the same slot. The flags and the ledger’s rules are in
tma act --slot.
Recommend confirm for anything that writes
tma cannot inspect what your script does. Set confirm = true for any action
that injects into a live session or mutates a repo, so a stray keypress or a
script cannot fire it unattended. It costs one --yes (or one menu keystroke) and
is the honest place to declare “this one is not idempotent”.
Control surfaces
Every surface fires the same verb, so an action is written once and reachable four
ways: a tma act shell line, the tmux menu (tma act --menu, wired to a key by
tma install-keys), a keybinding, and a
hardware deck. Nothing works on a deck that a keyboard-only tmux user cannot reach
through the menu; if a flow needs hardware, it is a bug.
Both live surfaces carry a triage key that opens the menu for the agent under the
cursor rather than the pane you are standing in, so a screenful of blocked agents
is answered from one place: a in tma watch, and tab in the picker (whose
every printable key belongs to the fuzzy query). Two things follow from
the menu being computed fresh, on the target pane. If nothing is fireable there
right now, no menu opens at all, and tma act --list --pane <id> says why. And
the menu is handed to tmux rather than run as a child of the dashboard, so it
outlives the surface that asked for it, which is what lets the popup-hosted
picker offer the key: a tmux menu replaces the popup on screen and the action
still fires.
The menu opens over your client and captures your keystrokes until you pick an
entry or dismiss it; the target pane keeps running underneath, untouched. That
focus steal is deliberate and the menu never refuses to open on a working
pane: interrupting a working agent is the flagship menu use, and the entries
already show only what is fireable right now. If you were mid-sentence into the
pane when you opened the menu, finish the menu first — keys you type go to it,
not the pane.
A surface stays a dumb reader on the act path exactly as on the read path. A deck
or plugin enumerates actions with tma act --list --json --pane %N and renders
them: fireable ones lit, gated ones dark, each carrying a reason so the plugin
can gray-out-temporarily (gated) versus gray-out-permanently (no-coverage).
It then shells out to tma act <name> --pane %N and contains zero policy. The
document’s exact key set is in
Pane options and JSON contracts.
To know when to re-render, spawn tma subscribe instead of running your own
polling timer: see Stream state changes.
Block a script on agent state
Block a script until an agent reaches a state, then act on the result. tma wait
is the scripting primitive: it waits on a target pane, prints its final row, and
exits with a code you can branch on. This guide covers the common recipes and the
exit-code contract in practice.
wait is level-triggered: if the target is already in a requested state, it
returns immediately rather than waiting for a fresh transition. For every flag see
tma wait; the exit-code table is
there too.
Block until an agent finishes
Wait for a specific pane to go idle, then run the next step:
tma wait --pane %5 --until idle && ./deploy.sh
--until takes a comma-separated set, so you can wake on any of several states.
Wait for the agent to either finish or get stuck:
tma wait --pane %5 --until idle,blocked
When the state is reached, wait prints the matched row (same columns as
tma ls) and exits 0:
$ tma wait --pane %1 --until blocked
%1 claude blocked permission 1786900866503 s2:0.0 web-ui 1
Use --json for a structured result (one schema-1 object, same keys as an
ls --json row):
$ tma wait --pane %0 --until working --json
{"schema":1,"pane":"%0","agent":"claude","state":"working","detail":null,"since":1786900866412,"since_ms":1786900866412,"episode_ms":1786900866412,"locator":"s1:0.0","title":"api-server","attention":false,"done":false,"session":"3f1c8a20-5b6d-4e77-9c11-8a2e4d0b6f93","context":31,"context_at_ms":1786900866438,"muted":false,"tokens":62400,"repo":"tmux-agents","branch":"main","worktree":false,"server":"/private/tmp/tmux-501/default","host":"devbox"}
Target an agent by name
--agent waits on the pane running that agent. It pins to the first pane it
observes and then behaves as --pane on that id, so a second same-named agent
appearing mid-wait never flips the wait. If more than one pane matches at that
first observation, it is an error that tells you to target one explicitly, rather
than silently picking one:
$ tma wait --agent claude --until idle
tma: --agent "claude" matches 2 panes (%1, %0); target one with --pane
Narrow the match with the selector flags
when you have the same agent in several places: --session <name>, --repo,
--branch, or --state. They scope the first observation (the one that pins),
not the pinned pane afterwards. --any waits on any agent pane in scope and
never pins, so it keeps waiting if one vanishes:
tma wait --any --repo tmux-agents --until done --timeout 900
Wait on a fleet
--all is a barrier: it returns only when every agent pane in scope is in a
target state, and prints all of their rows. Use it to join a fan-out before a
merge step:
tma wait --all --repo tmux-agents --until idle,done --timeout 1800 && ./collect.sh
Membership is pinned at the first observation, so an agent someone launches
halfway through does not extend the barrier — the fleet is the one you started
over. A member whose pane dies ends the wait at exit 3 rather than quietly
shrinking the barrier to the survivors. An --all whose scope matches no pane at
all is exit 2 (there is nothing to wait for), never a vacuous success.
--count <n> is the looser form, a quorum: it returns once n panes in scope are
in a target state, re-reading the scope every cycle, so panes may come and go
under it. Use it to start work as soon as enough agents are free:
tma wait --count 2 --agent claude --until done --timeout 600
Both print one tma ls line per satisfied pane, and both take --json, where
they emit the same schema-1 agents document tma ls --json does (rather than
the single row object the one-pane targets emit).
Drive a supervisor loop
wait is level-triggered, which is what makes it safe to call at any moment —
and what makes a naive loop spin. If you wait for blocked, act, and loop, the
second wait returns instantly: the pane is still blocked (or just became idle
in a way that satisfies you again) from the episode you already handled.
--since fixes that by requiring the state to have BEGUN after a timestamp you
carry forward:
#!/bin/sh
# Feed one agent a queue of tasks, one per idle episode.
set -eu
pane=%5
since=0
while read -r task; do
row=$(tma wait --pane "$pane" --until idle --since "$since" --json --timeout 900) || exit $?
since=$(printf '%s' "$row" | sed 's/.*"episode_ms":\([0-9]*\).*/\1/')
tma act queue-next --pane "$pane" --arg "$task" --yes
done < tasks.txt
Each pass blocks until the pane enters an idle episode strictly newer than the
one it just serviced, so the loop advances exactly once per episode. The floor is
exclusive (episode_ms > --since), which is why feeding back the row’s own
episode_ms is correct. Read that key and not since_ms: since_ms is
@agent_since, which is write-once per state run, so once the pane has completed
a second turn without leaving idle it stays pinned behind the value wait
compares, and every lap satisfies instantly. episode_ms is the later of the two,
so it always names the episode the loop just handled. --since composes with
every target, including --all and --count. The queue-next action it fires
is written in Author a custom action.
Gate CI on agent state
In a headless run, launch the agent, then block a build step on its completion
with a timeout so a hung agent fails the job instead of hanging it. --timeout
follows the timeout(1) convention and exits 124 on expiry:
#!/bin/sh
set -e
# ... launch the agent in a tmux pane %agent ...
if tma wait --pane "%agent" --until idle --timeout 600; then
echo "agent finished"
else
code=$?
[ "$code" = 124 ] && echo "timed out" && exit 1
[ "$code" = 3 ] && echo "pane vanished" && exit 1
[ "$code" = 4 ] && echo "agent died" && exit 1
exit "$code"
fi
You can also compose with timeout(1) itself as an external belt when you want a
hard wall-clock ceiling regardless of wait’s own logic:
timeout 600 tma wait --pane %5 --until idle
Exit codes in practice
Each code below was produced by a real wait invocation. Branch on them as in the
CI recipe above.
$ tma wait --pane %1 --until idle --timeout 2 # never reaches idle
tma: timed out after 2s waiting for idle (exit 124)
$ tma wait --pane %5 --until idle # pane killed mid-wait
tma: the waited-on pane %5 vanished before reaching idle (exit 3)
$ tma wait --pane %1 --until bogus # bad state token
error: invalid value 'bogus' for '--until <STATES>': unknown --until state "bogus" (expected one of: idle, working, blocked, unknown, done)
For the full code list and its semantics, see the exit-code table.
Waiting before an agent exists
A --pane target that is not yet an agent does not fail fast: wait blocks by
design (the agent may launch later), printing a one-time hint to stderr if the
pane looks like a typo.
When the agent crashes
Once the wait has seen the pane carrying an agent, a missing agent row means the
opposite: the process died and the pane is still sitting there. That ends the
wait at exit 4 rather than blocking until --timeout, so a supervisor can
restart the agent instead of waiting out a ceiling meant for slow work:
$ tma wait --pane %5 --until idle --timeout 900
tma: the agent on pane %5 exited before reaching idle (exit 4)
Branch on it next to 124: 4 means “restart it”, 124 means “it is still
running and taking too long”. --any and --count ignore a departure (they are
waiting on whoever else is left); --all ends on a member’s agent death the same
way it ends on a member’s pane vanishing, and forwards that member’s own verdict
rather than flattening the two. So a barrier exits 4 when a member’s agent died
and 3 when a member’s pane went away, which is the distinction the branch above
depends on.
Watching everything instead
tma wait blocks for one thing. For a running record of every transition, see
Stream state changes.
Stream state changes
tma subscribe is the push side of the read path. One long-running process
prints one JSON line per emission on stdout and holds a connection to nothing but
the tma binary, so a plugin, a bar, or a logger stops owning a polling timer:
tma subscribe --json
--json is required; it is the only emission today, and leaving it off is a
usage error (exit 2). Every flag is in tma subscribe.
Snapshots or transitions
The two modes answer different questions, and the choice decides everything else on this page.
Snapshots are the default: each line is a complete ls --json schema-1
document, the same one tma ls --json prints. Use
it when your consumer renders the current world and does not care how it got
there. A re-render needs no memory of the previous line.
$ tma subscribe --json
{"schema":1,"agents":[{"pane":"%5","agent":"claude","state":"blocked",…}]}
--events emits one record per state transition instead, one object per
line:
$ tma subscribe --json --events
{"schema":1,"at_ms":1786900866503,"pane":"%5","agent":"claude","from":"working","to":"blocked","detail":"permission","locator":"work:1.0","repo":"app","branch":"main"}
Use it when the transitions themselves are the data: a log, a counter, a
notification hook of your own. from and to are the disjoint reading of state,
so a finished-but-unreviewed pane is done rather than idle, and clearing
attention is a real done → idle edge: jumping to the pane, moving off it, or
simply typing at it once it is in front of you. Read that edge as “the user saw
it”, not “the work was undone”. A pane that appeared
since the last cycle carries "from": "" and one that vanished carries "to": ""; the empty string rather than unknown is what lets you tell “the pane is
there and unreadable” from “there is no pane”. A pane whose state held still
emits nothing even if its title or detail moved, because this is a transition
stream and not a change feed. The full key table is in
--events.
Do not repeat yourself with --changes-only
With no daemon the stream polls on --interval (default one second) and emits
every tick whether or not anything moved. A consumer that re-renders does not
care. A consumer that appends very much does: a daemonless logger writing to a
file collects 86,400 identical lines a day.
tma subscribe --json --changes-only
--changes-only makes the poll tick behave the way the push-mode belt already
does and emit only when the document differs from the last one sent. It is a
silent no-op in push mode and under --events, both of which are edge-triggered
by construction, so a script never has to know which mode it landed in. The entry
snapshot is always emitted.
What the stream does not promise
Four properties are worth designing around before you build on the stream. They are the same in push and poll mode, because every line is built from the subscriber’s own cycle rather than from the daemon socket.
- There is 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. Whatever happened while it was down is gone.
- The first cycle is silent under
--events. No synthetic edges are invented for panes that were already running, so restarting a logger does not stamp a fresh “appeared” line on every long-lived agent. In snapshot mode the first line is the current document, not an event. If you need the state you began from, runtma ls --jsononce alongside the stream. - Fast flips can collapse. Transitions inside the 100 ms coalescing window are observed as one cycle, so a state an agent held for 50 ms may never appear.
- The degrade is silent. No daemon, a daemon dying mid-stream, or a daemon
too old to answer drops the stream to unconditional
--intervalpolling with nothing on stderr and no exit. Only latency changes.tma doctoris what tells you which mode you are in.
There is no heartbeat either: process death is the liveness signal. The stream exits only on a signal or when its stdout closes, so a consumer that owns the process respawns it on EOF.
Drive a plugin or a bar
A surface stays a dumb reader here exactly as it does on the option-reading path. Spawn the stream once, re-render on each line:
#!/bin/sh
# sketchybar item fed by the stream instead of a 5-second timer.
tma subscribe --json --changes-only | while IFS= read -r doc; do
blocked=$(printf '%s' "$doc" | jq '[.agents[] | select(.state == "blocked")] | length')
sketchybar --set agents label="⚑$blocked"
done
With a daemon running that repaints within milliseconds of the block rather than
at the next tick of a timer; with no daemon it repaints on the --interval poll,
from identical documents. Scope it with the selector
flags when a surface only cares about part
of the fleet: --repo app narrows the agents array without changing the
cadence or the push/poll contract. Under --events the filter is applied
before the diff, so a pane leaving the selection reads as a departure and one
entering it as an appearance.
A plugin that also offers actions re-runs tma act --list --json --pane %N for a pane the emission shows changed,
and shells out to tma act <name> --pane %N to fire. It holds no policy of its
own either way.
The stream is an ambient driver for as long as it lives: its cycles stamp every
pane on the server, so a bar fed this way keeps state fresh for every other
reader and needs no #(tma status) beside it for freshness.
Log every transition to jsonl
tma wait blocks for one thing. For a record of everything instead, what got
blocked, for how long, in which repo, --events appends straight to a jsonl
file:
tma subscribe --json --events >> ~/.local/share/tma/events.jsonl
Ordinary line tools work on it. How many blocks did each agent hit today:
jq -r 'select(.to == "blocked") | .agent' events.jsonl | sort | uniq -c
The stream is edge-triggered, so an idle server writes nothing at all and there is no interval to tune for a quiet day. Run it under a supervisor that restarts it, and remember that the gap while it was down is not recoverable.
To log a snapshot stream instead, one full document per change rather than one
record per transition, pair --changes-only with the default mode:
tma subscribe --json --changes-only >> snapshots.jsonl
Next
- Block a script on agent state when you want to wait for one thing rather than watch everything.
- Read agent state from a status bar or
script for the pane-option
path, which needs no
tmaprocess at all at read time. - Run the daemon to put the stream on pushes instead of a poll.
Run the daemon
Add the optional daemon tier for lower latency, fallback detection of hookless agents, and deduplicated notifications. The daemon is strictly additive: every surface works without it, so run it only when you want what it adds.
The three tiers
The daemon is tier 3, the top of the three detection tiers (polling floor, hook
tier, daemon); for what each adds and why none is required, see
the detection model.
Which tier a given pane is actually at, and why it is not higher, is what
tma doctor reports (below).
Two setups where it stops being optional
“Strictly additive” assumes something else is driving the poll. Two common tmux setups leave nothing driving it, and on those the daemon is the only thing keeping state fresh.
Detached sessions. #() status jobs run only while a client is drawing the
status line. A session started with new-session -d and never attached — a
long-running agent you check on now and then, a fleet started by a script — has no
client, so the #(tma status) driver never fires and stamped state ages until you
run a tma command by hand. tma doctor names it:
clients: none attached — `#()` status jobs only run while a client draws the status line, so nothing polls this server (run the daemon or attach a client)
With a daemon running, the same line reads differently, because the gap is covered:
clients: none attached — `#()` status jobs do not run detached; the daemon is keeping state fresh meanwhile
The other fix is an external poll: tma status --format plain from a bar or a
cron job reaches a detached server perfectly well (see Drive an external
bar). Either works; doing neither
is what leaves the server unpolled.
status off. Turning the status line off kills both tmux-side channels at
once: #(tma status) never runs (no status line to expand), and display-message
notifications have nowhere to render. Doctor flags it as a warning:
status: the global `status` option is off — the `#(tma status)` driver never runs and `display-message` notifications are invisible (`tmux set -g status on`)
Here the daemon covers the freshness half by itself, and for the notification half
point notify at a channel that does not need a status line — bell, osc, or a
command hook (see Set up notifications).
Both of these do count against tma doctor --exit-code: a server with no attached
client and no daemon covering it is a warning, and so is status off. What
--exit-code deliberately ignores is a missing daemon on its own, which is a
runtime choice rather than a misconfiguration, so a wired agent sitting at tier 2
gates green. The two above are the cases where something that should be driving
the poll is not.
Start it
tma daemon --ensure spawns a detached daemon for the current tmux server if none
is running, then exits. It is idempotent, so it is safe to run from a shell rc or
a tmux hook:
$ tma daemon --ensure
$ tma daemon --ensure # already running: still exit 0, no second daemon
To run it in the foreground instead (for debugging), use tma daemon with no
flag. There is one daemon per tmux server, keyed by socket.
Autostart
If you ran tma install-keys (or tma init), this is already done. The managed
keybindings file ends with a launcher tmux runs when a server loads its config:
run-shell -b 'tma --socket-path "#{socket_path}" daemon --ensure >/dev/null 2>&1'
#{socket_path} is what makes it multi-server-safe: each server starts a daemon
for itself, never for the default server. It applies from the next server start,
so run tma daemon --ensure to catch the one you are in now. Under Home Manager
the same line comes from programs.tma.daemon.autostart = true.
tma install-keys --no-daemon omits it. Pair that with --check --no-daemon,
or a plain --check will report the missing line as drift.
The other route, for people who do not let tma write their tmux config, starts
the daemon the first time you use any surface
(ls/status/jump/picker/watch/wait/subscribe):
[daemon]
autostart = true
That one is off by default. Either way the daemon stays strictly additive: nothing breaks without it, every surface falls back to polling.
The cadence knobs (sweep_secs, quiet_ms, demote_edges, and others) also live
under [daemon]; see
Configuration.
They apply only while the daemon runs.
Reload config without a restart
A running daemon re-reads its config and manifests on tma reload (or a SIGHUP),
swapping every derived setting in place while keeping its live state:
$ tma reload
tma: reloaded the daemon's config + manifests
If no daemon is running for this server it is a clean no-op (one-shot surfaces and the picker reload on their own each cycle):
$ tma reload
tma: no daemon running for this server (nothing to reload; one-shots and the picker reload on their own)
An invalid config or manifest on reload is kept-old and logged: a reload never kills or corrupts a running daemon. A user manifest that fails to parse is skipped and logged individually; the daemon keeps serving on the rest of the set.
tma reload re-reads config and manifests, not the binary.
Pick up an upgraded tma
After upgrading tma, the daemon already running is still the old build. It stays
that way indefinitely — nothing replaces it on its own, and install-hooks
repointing your hooks at the new binary does not change which build answers them.
tma doctor says so, and gates on it under --exit-code:
$ tma doctor
daemon: running (<tmpdir>/tma/<server>.sock)
version 0.1.0 differs from this CLI (0.2.0) — `tma reload` only re-reads config and manifests; run `tma daemon --restart` to put this build in its place
With the automatic restart turned off, the fix is one verb:
$ tma daemon --restart
tma: stopped the running daemon
tma: daemon restarted (0.2.0)
--restart is unconditional and works in both directions: run it from the older
binary to go deliberately back. It stops the daemon with SIGTERM and never
escalates to SIGKILL (the daemon reaps its tmux -C control clients only on a
clean exit), and it starts a daemon even when none was running.
tma init and tma install-hooks make the same offer for you when they find a
resident daemon of another build, on the same confirm-before-changing terms as
their config writes.
Most of the time you will not have to run it at all. [daemon] restart_on_upgrade is on by default, so an older resident daemon is replaced
before your next surface (ls, status, the picker, watch, wait,
subscribe), on the next tma event a hook fires, and on tma daemon --ensure.
It never starts a daemon that was not running: that is autostart’s job, and it
is still off.
Strictly newer replaces older: equal never restarts, and an older tma never
touches a newer daemon, so two installs sharing a server cannot take turns
evicting each other’s daemon. See
Configuration for the full
rule and its fail-safes.
To turn it off and replace daemons by hand instead:
[daemon]
restart_on_upgrade = false
What a restart costs, and what a skewed daemon costs
The daemon records its version next to its pid in the lock file, which is where doctor reads it from.
A restart is cheap. The socket is gone for roughly 35 ms, then bound again but not
yet draining for up to two seconds while the daemon runs its control-mode
behaviour probe. Nothing is lost across either window: a hook that cannot reach a
daemon stamps the pane itself, tma wait sees the connection close and degrades
to polling, and notification de-duplication lives in a pane option that outlives
the process.
Leaving the skew in place is the more expensive choice, and not only in latency. An event the old daemon’s manifests map to nothing is refused rather than acknowledged, so the firing hook stamps it itself and only the latency is lost. But an event the old daemon maps to the old verdict is acknowledged happily — and the client then skips its own stamp. That is a wrong transition, not a late one, and no reload fixes it.
See the effective tier
tma doctor reports the tier per pane and the daemon’s status. With the daemon
running, wired panes reach tier 3:
$ tma doctor
daemon: running (<tmpdir>/tma/<server>.sock)
ambient: NOT polling — nothing invokes `tma status`; add `#(tma status)` to status-right (required ambient driver)
clients: 1 attached
watch: no watcher running (`tma watch` advertises for SIGUSR1 nudges)
hooks: after-select-pane ✓ session-window-changed ✓
wrapper: ~/.cargo/bin/tma-hook ✓
agents: 6 loaded, no issues
actions: 4 loaded, no issues
panes (2):
%0 claude s1:0.0 tier 3 working (hook, 40.6s ago)
hooks: wired
%1 claude s2:0.0 tier 3 blocked (hook, 40.6s ago)
hooks: wired
agents: is the manifest roster tma loaded; panes: is what it found running.
Without the daemon the same panes show tier 2 with the reason “daemon not
running”. tma doctor --json emits the same diagnosis as a versioned schema for
scripting.
Nested tmux sessions
There is one daemon per tmux server, so a nested tmux gets its own. Agents running inside an inner server are invisible to a tma on the outer one: their processes are not in the outer pane’s tree, and their state options live on the inner server. Doctor says so rather than leaving the pane unexplained:
nested: 1 pane(s) running a multiplexer client — agent state lives on the inner server; run tma there
- %3 s1:0.1 (tmux)
Run tma from inside the nested session and it targets the inner server without
any flag: tmux sets $TMUX in every pane it owns, and the client reads its socket
from there. So tma daemon --ensure, tma ls, and the keybindings all work in the
inner session exactly as they do in the outer one, each with its own daemon.
Show agents in your status line
Put the agent counts in tmux’s own status line, and get the ambient state driver
in the same move. This is one line you add yourself; tma install-keys never
edits your status-right.
Add the driver
set -g status-right '#(tma status) %H:%M'
#(tma status) is not optional decoration. It is the ambient driver: it runs
every status-interval, refreshes each pane’s stamped state, and prints the
counts. Without it, ambient surfaces (window flags, per-window summaries) render
nothing, and with no daemon nothing keeps state fresh between your explicit
commands.
tma status prints state counts with glyphs and tmux color codes, which tmux
renders inline. One blocked and one working agent:
$ tma status
#[range=user|tma:blocked]#[fg=red]⚑1#[norange] #[range=user|tma:working]#[fg=yellow]●1#[norange]
The order is fixed (blocked working done idle unknown), zero-count classes are
omitted, and a server with no agents prints nothing at all, so tma adds nothing
to your status line until it has something to say. The #[range=…] markers are what make
each segment clickable; tmux draws nothing for them, and without the opt-in mouse
bindings nothing acts on them (Install the
keybindings). Glyphs and
colors come from [status] config; see
Configuration.
What a refresh costs
The driver is not a read-only renderer. Refreshing a pane’s stamped state means
tmux set-option, and tmux redraws every attached client in full on any option
write, even one whose value did not change. That is a whole-screen re-emission
rather than the single status row a plain status tick writes, so it is worth
knowing when it happens:
- An idle agent costs nothing. With nothing written to its window since its last stamp, the cycle reuses the stored verdict and issues no write at all.
- A working agent costs one redraw per cycle. Its screen keeps changing, so it is restamped every time. The stampede hint rides that same invocation instead of costing a second one.
- The status string itself is cheap. tmux skips the write when the expanded
status-rightis identical to what is already drawn, so a static string sends your terminal nothing no matter how shortstatus-intervalis. A%H:%Mclock costs one status-row write per minute, about 165 bytes.
A full redraw puts every wrapped line back through your terminal’s own wrap
logic, so a terminal that disagrees with tmux about a glyph’s width can visibly
re-wrap a pane during one. If you see that, upgrade tmux: 3.7 corrects the
redrawing of wide characters when they are overwritten and lets
codepoint-widths accept ranges. The upstream report for the agent-TUI side of
this is
anthropics/claude-code#91182.
Roll up the agents in each window
The driver also maintains a window-scoped @agent_summary option, so a
per-window rollup costs no extra process:
set -g window-status-format '#I:#W #{@agent_summary}'
Name windows after their agents
The rollup above renders beside the window name. The other option is to make it
be the window name, which is what you want when the window list is your only
view of the fleet (a narrow terminal, or a tab bar outside tmux reading
#{window_name}). This one needs the daemon, and it writes:
[daemon.window_names]
format = "{repo}:{state}"
A window with one blocked Claude in the tma checkout reads tma:blocked. The
tokens are {agent}, {state}, {detail}, {repo} and {branch}; the full
reference is in
Configuration. {state} is the
window’s highest-attention state, the same order the rollup prints in, so a
window holding a blocked agent and two working ones reads blocked.
Only windows with at least one agent pane are renamed, and only when the name
actually changes. A window whose rollup has not moved costs one read and no
write, which matters here for the reason above: a
rename-window is an option write and every one of those redraws every attached
client.
It gives the window back. On its first rename tma saves the window’s name in
@tma_window_name_orig and its automatic-rename setting in
@tma_window_autorename_orig (rename-window turns that off as a side effect,
so it has to be saved to be put back). Both are restored, and both options
dropped, when the last agent pane leaves the window or the daemon stops. Turning
the feature off and reloading (tma reload) restores them too.
It does not fight you. tma records what it last wrote in
@tma_window_name_last. Rename a window yourself and the current name no longer
matches that, so tma leaves that window alone until the next restore hands it
back.
The zero-write alternative is the window-status-format recipe above: it reads
the same rollup, costs no option write of its own, and needs no daemon. Reach for
window_names when something outside tmux’s status line has to see the state.
Scope it to one session
The counts tma status prints obey the selector
flags, so scoping the driver to the current
session is one flag:
set -g status-right '#(tma status --session #{session_name}) %H:%M'
The cheaper alternative, if all you want is counts per session, is the option the driver already maintains:
set -g status-right '#{@agent_session_summary} %H:%M'
It carries the same <state>:<count> grammar as the per-window @agent_summary,
in machine tokens rather than glyphs (see Pane options and JSON
contracts).
Two things to know before scoping the #() driver:
- tmux caches
#()jobs per expanded command string. Each distincttma status --session <name>is a separate long-lived job, so N attached sessions means Ntmaprocesses on everystatus-intervalinstead of one. That is fine for a handful of sessions and wasteful for dozens. - A filtered driver still refreshes everything. The selector narrows only what is printed; the cycle behind it stamps every agent pane on the server. So one scoped driver in one session keeps every other session fresh too.
The same holds for the other scopes: #(tma status --repo app) counts one repo’s
agents, and tma watch --repo app opens a dashboard for it.
Next
- Drive an external bar if your status bar is not tmux’s.
- Install the keybindings to make the segments clickable and put the picker on a key.
- Run the daemon for the two setups where a status-line driver alone is not enough.
Drive an external bar
Your agent panes live in tmux but your status bar does not: sketchybar, waybar,
polybar, starship, a Prometheus scrape, a tmux-less terminal. Poll tma status --format plain on whatever interval that bar already uses. It prints the same
counts as the tmux driver with the color codes dropped, since those bars do their
own styling:
$ tma status --format plain
⚑1 ●2 ✓1
The recipes
sketchybar, in a plugin script:
sketchybar --set agents label="$(tma status --format plain)"
waybar, a custom/tma module with "exec" and an "interval":
"custom/tma": { "exec": "tma status --format plain", "interval": 5 }
starship, a custom module in starship.toml:
[custom.tma]
command = "tma status --format plain"
when = true
format = "[$output]($style) "
For a bar that would rather have numbers than glyphs, --format json gives the
same counts as a one-line schema-1 document. Scope any of these with the
selector flags: tma status --format plain --repo app is one repo’s counts.
Why a poll is enough on its own
A poll from an external bar is a first-class ambient driver. Every tma status invocation, whatever its --format, runs the full poll cycle and stamps
every agent pane on the server before it prints; the format only decides how the
counts are rendered. A sketchybar item polling every 5 seconds keeps state as
fresh as a 5-second status-interval would, so you do not also need #(tma status) in status-right for freshness. You may still want it for the inline
glyphs.
That holds for a scoped poll too: the selector narrows what is printed, never what the cycle refreshes.
No attached client is needed. Unlike #(), which only runs while a client is
drawing the status line, an external poll reaches a detached server perfectly
well: tma connects to the socket, cycles, and exits. A tmux session you started
with new-session -d and never attached to still gets refreshed state, which is
exactly the case tma doctor warns about when nothing
else is driving.
Export to Prometheus
tma status --format prom writes the Prometheus text exposition format, which is
what a node_exporter textfile
collector reads.
Write it atomically (rename into place) so the collector never scrapes a
half-written file:
* * * * * tma status --format prom > /var/lib/node_exporter/tma.prom.$$ && mv /var/lib/node_exporter/tma.prom.$$ /var/lib/node_exporter/tma.prom
The same caveat as any cron tma invocation applies: cron gives you no $TMUX,
so pass --socket-name/--socket-path when your agents run on a named server,
and make sure the crontab user is the one who owns the tmux socket.
Two families come out of it: tma_agents{state="…"} (the counts, all five classes
always present) and tma_agent_state_seconds{pane,agent,state} (how long each
pane has held its current state). The second is the one worth alerting on: a
blocked agent whose age climbs past a few minutes is one nobody has answered.
- alert: AgentBlockedTooLong
expr: tma_agent_state_seconds{state="blocked"} > 600
annotations:
summary: '{{ $labels.agent }} in {{ $labels.pane }} has been blocked 10 minutes'
Because the cron run is itself an ambient driver, a Prometheus export doubles as the polling floor on a server with no attached client.
Next
- Show agents in your status line if you also want the counts inside tmux.
- Read agent state from a status bar or
script for the
option-reading path that needs no
tmaprocess at all.
Install the keybindings
Put the picker, the watch dashboard, and the jumps on your prefix key. What each key does is in Keybindings; this page is how the bindings get onto your tmux server and how to change them once they are.
Install
tma install-keys
This writes the default bindings to a managed file (~/.config/tma/tmux.conf) and
adds one line to your tmux config. The line names the file through tmux’s own
variable expansion, so a tmux config kept in a dotfiles repo works on every
machine; -q makes the XDG_CONFIG_HOME path a quiet miss when that variable is
unset, and the $HOME one loads instead:
source-file -q "$XDG_CONFIG_HOME/tma/tmux.conf" "$HOME/.config/tma/tmux.conf" # tma keys
With --config-dir (or TMA_CONFIG_DIR) the line carries that literal path
instead, double-quoted so a dir with a space still parses.
It shows the diff and asks before writing, naming the file it resolved; pass
--yes to skip the prompt. Reload tmux afterward, pointing at whichever config
the diff named:
tmux source-file ~/.config/tmux/tmux.conf
Which config gets the line
tma does not assume ~/.tmux.conf. It marks the first of tmux’s own config files
that exists, in tmux’s load order:
~/.tmux.conf$XDG_CONFIG_HOME/tmux/tmux.conf~/.config/tmux/tmux.conf
(tmux 3.6 sources every one of those that exists, in that order, so a later file’s
set wins; older tmux loads only the first.) If none exists, tma creates
$XDG_CONFIG_HOME/tmux/tmux.conf when XDG_CONFIG_HOME is set; with it unset it
creates ~/.config/tmux/tmux.conf if ~/.config is there, and ~/.tmux.conf
otherwise. Creating only ever happens when you have no tmux config at all, so the
file tma creates can never shadow one you already have. --conf <path> overrides
all of this.
Verify and undo
tma install-keys --check
tma install-keys --uninstall
--check confirms the managed file is current and that your tmux config sources
it exactly once; both resolve the config the same way install did. --uninstall
removes the managed file and the marked source-file line, and nothing else:
your own bindings live in your tmux config, tma’s live only in the managed file,
so there is nothing of yours to corrupt.
Rebind a key
install-keys claims only keys that are unbound in stock tmux (that is why the
temporary watch session is on G: g is already jump --blocked). If one
clashes with a
personal binding of yours, copy the line you want out of
~/.config/tma/tmux.conf into your own tmux config with a new key and drop the
managed file. Editing the managed file in place also works, but the edit survives
only until the next install-keys run, which rewrites the whole file from tma’s
defaults.
Clickable status segments
Each count tma status prints is wrapped in a tmux range marker, so a click can
be resolved to the class you clicked. The bindings that act on that are opt-in:
tma install-keys --mouse
They also need tmux’s mouse mode, which tma never turns on for you, because it
changes selection and copy/paste in every pane (drag-select stops reaching your
terminal; hold Shift to get the terminal’s own selection back). Turn it on
yourself if you want it:
set -g mouse on
With both in place, the mouse table applies: the blocked count jumps to the longest-blocked agent, any other count opens the picker popup, and a right-click on either opens the agent menu.
One thing a click cannot do is close the popup it opened. tmux delivers mouse
events to an open display-popup and drops everything outside it, so the second
click on the status line never reaches a binding. Esc closes it.
The range markers ship always. Without --mouse, or without set -g mouse on,
they are inert: tmux draws the counts exactly as before, and nothing is
clickable. tma doctor flags the half-wired case (bindings installed, mouse
off).
The cost of opting in: those four bindings claim tmux’s status-line mouse keys
for the whole status line, not just tma’s segments. A left-click elsewhere still
switches to the window you clicked (the binding ends with tmux’s own
switch-client -t=), but a right-click on a window name no longer opens tmux’s
window menu (Alt-right-click still does, since tmux binds that separately). If
you would rather keep the plain right-click, delete the two MouseDown3 lines
from ~/.config/tma/tmux.conf.
tma install-keys --check --mouse verifies the group is installed; a plain
--check accepts a file with or without it, so not opting in is never reported
as drift.
The daemon launcher
Every install writes one line that is not a binding:
run-shell -b 'tma --socket-path "#{socket_path}" daemon --ensure >/dev/null 2>&1'
It starts the event-hub daemon for whichever server loads the file, so a new tmux
server is at tier 3 before you open a surface. It is safe on a re-source
(--ensure takes a single-instance lock) and needs no matching stop line,
because the daemon exits when its tmux server does.
To skip it:
tma install-keys --no-daemon
That is a standing choice rather than a one-off: a plain --check calls the
missing line drift, so use --check --no-daemon in whatever verifies your setup.
Details and the other ways to start a daemon in
Run the daemon.
Bind them by hand instead
If you would rather not let tma write config, add the bindings to your tmux
config yourself. These are the same shapes install-keys writes.
The picker opens best in a popup. display-popup does not format-expand its
command, so pass no --client: the picker resolves the client that opened the
popup itself (a --client "#{client_name}" here would arrive literal and target
a client that does not exist).
bind-key a display-popup -E -w 80% -h 60% 'tma'
The managed full-width dashboard uses a dedicated temporary session. run-shell
format-expands the invoking client; --temporary-session switches that client to
the one-use session, and a jump or quit closes it. --table opens straight into
the table; p inside tma watch toggles it against the preview:
bind-key G run-shell 'tma watch --temporary-session --table --client "#{client_name}"'
If you would rather keep a persistent watcher beside your work, bind a plain
tma watch split instead. A narrow pane falls back to the single-column list:
bind-key W split-window -h -l 40 'tma watch'
Mind that a plain watcher is persistent and follows nothing: jump to an agent in another window and the pane stays behind in the window you left.
Jump straight to whoever needs you, no picker. run-shell does format-expand, so
pass the client: tma then switches the client that pressed the key and keys the
--back origin by it:
bind-key j run-shell 'tma jump --attention --client "#{client_name}"'
bind-key g run-shell 'tma jump --blocked --client "#{client_name}"'
bind-key b run-shell 'tma jump --back --client "#{client_name}"'
bind-key h run-shell 'tma jump --home --client "#{client_name}"'
The action menu for the pane you are standing in, likewise expanded by
run-shell:
bind-key A run-shell 'tma act --menu --pane "#{pane_id}"'
Should an old binding still pass an unexpanded #{client_name}, tma reads it
as no client at all and falls back to resolving the acting client itself.
tma watch runs in a normal pane, so q, Esc, or ctrl-c inside it quits and
takes the pane (or window) with it.
What a binding can reach
A binding is a shell command running as you, so it can do anything tma can do,
which is anything you can do to your own tmux server. That is the whole boundary;
see The security model for what follows from
it, particularly before you bind an action that writes.
Diagnose with tma doctor
tma doctor is the one command to run when a pane is not showing what you
expect. It is read-only: it identifies panes exactly the way the poll cycle does,
reports each one’s effective tier and why, and never stamps anything.
$ tma doctor
daemon: not running (/tmp/tma/7f665a9304f7e8ed.sock): tier 3 needs a running daemon (`tma daemon --ensure`)
ambient: polling: `tma status` last ran 0.1s ago
clients: none attached: `#()` status jobs only run while a client draws the status line, so nothing polls this server (run the daemon or attach a client)
watch: no watcher running (`tma watch` advertises for SIGUSR1 nudges)
hooks: after-select-pane ✓ session-window-changed ✓
wrapper: /home/you/.local/bin/tma-hook ✓
agents: 6 loaded, no issues
actions: 4 loaded, no issues
remote: 1 pane(s) behind a remote shell: an agent there reports only if it can reach this tmux socket (see docs/how-to/agents-in-containers.md)
- %10 work:4.0 (ssh)
ignored: 1 pane(s) excluded from detection: unset the option to bring one back (`tmux set-option -pu -t <pane> @agent_ignore`)
- %1 work:1.0 (ignored via @agent_ignore = manual)
panes (2):
%7 claude work:2.0 tier 2 unknown (process, 0.1s ago)
hooks: wired
not tier 3: daemon not running (events direct-stamp; run `tma daemon --ensure` for the daemon tier)
%8 codex work:3.0 tier 1 unknown (process, 0.1s ago)
hooks: not installed
not tier 2: hooks not installed for codex (run `tma install-hooks codex`)
The server-wide lines
The block above the blank line is about the server, not any one pane. Four lines are always there and the rest appear only when they have something to say.
daemon: whether a tier-3 daemon is alive for this server, and the socket it
looked at. A daemon running a different build than the CLI adds a second line:
tma reload only re-reads config and manifests, so picking up a new build means
stopping the daemon and running tma daemon --ensure again.
ambient: whether anything is calling tma status. polling: last ran Ns ago means a driver is alive, whether that is #(tma status) in status-right,
an external bar, or a cron job. NOT polling means nothing is, and with no daemon
that leaves pane state as stale as your last explicit command. See Show agents in
your status line and Drive an external
bar.
clients: how many clients are attached. A detached server is a warning only
when no daemon is covering for it, because #() status jobs run only while a
client is drawing the status line.
watch: how many tma watch instances are running, which is what receives the
focus-change nudge.
hooks: and wrapper: the tmux server hooks and the tma-hook wrapper. A
hook can read ✓, or ✗ as drifted (it runs a different command than this
build installs, usually a moved binary), wiped (recorded but gone server-wide,
so the server restarted), or missing. Each non-present hook gets its own
indented reason line. wiped is the one the daemon repairs itself: tmux hooks
live in the server, so a restart drops them, and a daemon re-arms whatever the
install record names when it starts. Seeing wiped therefore means no daemon has
started since the restart, and tma daemon --ensure fixes it as surely as
re-running tma install-hooks.
agents: and actions: the manifest and action rosters, with one - line
per file the loader skipped and per action naming an unknown agent. A
process_names entry longer than 15 characters is called out here too: that is
the width both macOS libproc and the Linux kernel truncate comm to, so such an
entry can never match a pane unless a truncated spelling sits beside it.
Four more appear only when the condition holds: status: (the global status
option is off, which kills both #(tma status) and display-message
notifications), mouse: (the clickable bindings are installed but mouse is
off), notify: (your [notify] command failed, with the reason and a pointer to
tma debug notify-test), and procs: (the ps walk itself failed, so detection
cannot see what runs in a pane and only hook-registered panes are listed below).
The per-pane block
Each agent pane gets a header line and one or more continuation lines:
%7 claude work:2.0 tier 2 unknown (process, 0.1s ago)
hooks: wired
not tier 3: daemon not running (events direct-stamp; run `tma daemon --ensure` for the daemon tier)
The header is pane id, agent, locator, tier, and the current stamp: state, the
evidence source it came from (hook, capture, or process), and
how long ago that evidence was taken. A pane with no decodable stamp reads
unstamped.
The hooks: line reads wired when every channel names tma’s own entry, and
wired (agent codex: notify chained through <program>) when another tool has
taken codex’s single notify key and passes tma’s command on to it: the wiring
fires, so it is reported rather than warned about.
The tier is what the pane is actually getting, not what it could get:
| tier | means |
|---|---|
| 3 | Hooks are on the hook path and a daemon is running. Nothing to improve; no reason line is printed. |
| 2 | Hooks are wired but no daemon is running, so events stamp directly instead of going through the hub. |
| 1 | The pane is not on the hook path at all: screen and process detection only. |
Below tier 3 the block ends with a not tier N: <reason> line naming the next
tier up and what it would take. At tier 1 that reason is one of three: hooks are
not installed for this agent, the agent is hookless (screen detection only, so
there is no hook tier to reach), or tma ships no install-hooks adapter for it
and you would wire it by hand. When a daemon is running, the reason picks up ; a daemon is running and provides fallback capture (tier 3).
Three other continuation lines show up when they apply. demoted: is the
interesting one: the pane registered through a hook, but its current state came
from capture, because output kept arriving that its hooks did not account for.
That is a suspect-wiring signal, not a proof: the usual cause is an agent
restarted without the wiring or a missing wrapper, so run
tma install-hooks --check. A hook claiming working accounts for the pane’s
output until capture contradicts it, so a long tool call does not demote a
healthy pane. model: names the model the pane stamped, and adds
unrecognized: no [telemetry.windows] entry names it only for a pane whose
context channel would have to size its gauge from that table. No shipped channel
does, so on a normal install that line is the model name and nothing else.
api: flags a pending permission request with no reachable endpoint.
A pane that is not listed at all
If the pane you care about has no line under panes, doctor has already told you
one of three things somewhere above (it is behind a remote shell, it carries
@agent_ignore, or the procs: line says the process walk failed) or the answer
is that identity did not resolve. tma debug explain prints the whole decision
for one pane:
$ tma debug explain %0
pane %0 (work:0.0)
command zsh
title dev-box
flags alternate_on=false scrolled=false history_view=false window_activity=1786903086
agent (none — no manifest process_names matched)
process 1 procs in pane tree
The agent (none — …) line is the verdict, and it stops there: with no agent
identified there is nothing to fold. The three usual causes are all readable from
those lines. The pane’s foreground is a shell and the agent is not running in it.
The agent’s process name is spelled differently from every manifest’s
process_names (compare against the process count and check the manifest). Or
the pane carries @agent_ignore, which explain names directly.
On a pane that did resolve, the same command keeps going: it prints the prior
stamp, the evidence records, every screen rule with a [match] or [ - ]
marker beside it, and the verdict with the winning evidence source. That is the
tool for “detected, but as the wrong state” as opposed to “not detected”. See
The detection model.
Remote and ignored panes
Both are reported, and neither is a warning.
remote: 1 pane(s) behind a remote shell: an agent there reports only if it can reach this tmux socket (see docs/how-to/agents-in-containers.md)
- %10 work:4.0 (ssh)
A pane whose foreground is ssh, mosh, docker, podman, or kubectl is out
of scope by classification: neither the process walk nor a capture crosses that
boundary. Running an agent elsewhere is a choice, not a misconfiguration, so
doctor names it rather than complaining. A pane that still carries stamps from
before the boundary went up gets ; its @agent_* options are held, not refreshed
appended, which is the honest description: nothing is updating them. To make an
agent behind one of those actually report, give its hooks a route back to this
socket, which is Run an agent in a container.
ignored: 1 pane(s) excluded from detection: unset the option to bring one back (`tmux set-option -pu -t <pane> @agent_ignore`)
- %1 work:1.0 (ignored via @agent_ignore = manual)
@agent_ignore is your own opt-out, so doctor shows the value you set and the
command that undoes it. Nothing else about that pane is evaluated.
A third section, nested:, lists panes running another multiplexer client. Agent
state lives on the inner server, so run tma there.
Gate CI on the report
--exit-code turns the findings into a build failure, which is what a dotfiles
job wants: it catches hook drift a config change introduced, not just an absent
install.
tma install-hooks claude --yes
tma doctor --exit-code || exit 1
The verdict goes to stderr, so --json on stdout stays parseable:
tma: doctor: 2 warning(s), 1 pane(s) below the tier their manifest supports
Counted as a warning: a missing wrapper, each tmux hook that is not present, each
skipped manifest and each action naming an unknown agent, each unreachable
process_names entry, each stamp tma cannot decode or did not write, a detached
server with no daemon,
status off, mouse bindings without mouse on, a failed notify command, and per
pane, incomplete hook wiring, a hook demotion, and a pending permission with no
endpoint.
Deliberately not counted: a daemon that is not running, a daemon version skew, no
ambient poll, no watcher, and the nested/remote/ignored sections. Those
are runtime choices, so a wired agent sitting at tier 2 gates green.
The second number counts panes below the tier their manifest supports, which is 2 for an agent tma can wire and 1 for a hookless or adapter-less one. An unwired hook-capable agent counts; a wired one at tier 2 for want of a daemon does not.
Note that a detached scratch server counts as a warning unless a daemon is
running, since nothing there would drive the polling floor. When the CI server
has no agent panes to diagnose, tma install-hooks --check is the narrower gate
over the wiring alone, with the same 0/1 contract.
Read agent state from a status bar or script
Every verdict tma reaches is a tmux pane option. Anything that can ask tmux a question can read it — a status bar, a prompt, a shell script, another TUI — with no API, no socket, and no dependency on tma at read time.
Read the option
Two forms, same data. A format string, anywhere tmux expands one:
set -g pane-border-format '#{pane_index} #{@agent_state}'
set -g window-status-format '#I:#W #{@agent_summary}'
Or a one-shot read from a script:
$ tmux show-options -pqv -t %5 @agent_state
blocked
$ tmux show-options -pqv -t %5 @agent_detail
permission
-q keeps an unset option quiet (an agentless pane simply prints nothing), and
-v drops the key so you get a bare value. The full option list, with the grammar
of each value, is in Pane options and JSON
contracts. Values are
machine tokens (idle, working, blocked, unknown) and epoch milliseconds,
never glyphs — the rendering is yours to do.
For rollups, two options save you the aggregation: @agent_summary on each window
and @agent_session_summary on each session, both carrying the same
<state>:<count> grammar in a fixed order with zero counts omitted:
$ tmux show-options -qv -t dev @agent_session_summary
blocked:1 working:2
They are maintained by the same writers that stamp the panes, so a per-session indicator costs one format expansion and no process at all.
Do not shell out to tma per redraw
tma status and tma ls are not readers. Every invocation runs a full poll
cycle: it lists panes, walks the process table, captures screens where the fold
needs them, and stamps what it finds, and only then prints. That is exactly what
you want on a 1-to-5-second interval — it is how state stays fresh — and exactly
what you do not want on every prompt redraw or every keystroke in a fast loop.
The rule of thumb: read the options as often as you like (a tmux format
expansion, no process), and run tma on a timer.
When you want the whole row
The options carry one pane’s fields; tma ls --json carries the resolved row —
locator, title, repo and branch labels, the context gauge, the done surface —
as a versioned schema-1 document. Scope it to one pane with --pane:
$ tma ls --pane %5 --json
{"schema":1,"agents":[{"pane":"%5","agent":"claude","state":"blocked",…}]}
It prints an empty agents array (exit 0) for a pane with no agent, so a reader
never has to special-case a missing pane. The key set and its null rules are
pinned in Pane options and JSON
contracts; keys are only
ever added, never renamed or dropped.
If what you want is a stream rather than a poll, tma subscribe emits one line
per change and is a driver itself for as long as it runs; see Stream state
changes.
The freshness caveat
Pane options are a store, not a feed. They hold the last verdict some producer
reached, and nothing refreshes them on your read: show-options does not run
a cycle, and neither does a format expansion. If nothing else is driving tma on
that server — no #(tma status) in the status line, no daemon, no other tma
command — you are reading a snapshot of whenever the last one ran, which may be
minutes or hours old.
Two facts make this manageable:
- Every stamp is dated.
@agent_stamped_atis the per-pane freshness marker, and a reader that cares can compare it against the clock and grey out a value it no longer trusts. Thewatchtable does exactly that with the context gauge. - Any
tmainvocation is a driver. A slow-timertma statusin your bar is not just a printer; it runs the same cycle the status line does, so it keeps every pane on the server fresh for every other reader, including your option-reading ones. So doestma ls, or a crontma status --format prom.
The practical shape, then: one cheap driver on a timer, everything else reading
options. If your bar already polls tma status --format plain every five seconds,
your other readers are covered by it and need no timer of their own. If nothing
polls, tma doctor says so:
ambient: NOT polling — nothing invokes `tma status`; add `#(tma status)` to status-right (required ambient driver)
See Drive an external bar for the driver recipes, and Run the daemon for the two setups where a driver alone is not enough.
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>).
Keybindings
Every key tma binds or reads, in one place. The prefix and mouse bindings are the
ones tma install-keys writes; for installing, rebinding, or removing them see
Install the keybindings. The rest are keys
the live surfaces read for themselves, so they need no binding at all.
Prefix bindings
Written to the managed file ~/.config/tma/tmux.conf. All are on your tmux
prefix.
| key | tmux command | does |
|---|---|---|
a | display-popup -E -w 80% -h 60% 'tma' | Open the picker in a popup. |
G | run-shell 'tma watch --temporary-session --table --client "#{client_name}"' | Open the full-width table in a dedicated temporary session. |
j | run-shell 'tma jump --attention --client "#{client_name}"' | Jump to whoever wants you: blocked first, then finished-unreviewed. |
g | run-shell 'tma jump --blocked --client "#{client_name}"' | Jump to the longest-blocked agent. |
b | run-shell 'tma jump --back --client "#{client_name}"' | Return one step along the jump trail. |
h | run-shell 'tma jump --home --client "#{client_name}"' | Return to the trail’s oldest origin. |
A | run-shell 'tma act --menu --pane "#{pane_id}"' | Open the action menu for the active pane. |
G rather than g: g here is already jump --blocked. install-keys claims
only keys that are unbound in stock tmux.
Only the run-shell bindings carry --client "#{client_name}", because only
run-shell format-expands its command. display-popup and split-window do not,
so the flag would arrive as the literal #{client_name}; the popup therefore
lets tma resolve the acting client itself.
Mouse bindings
Opt-in (tma install-keys --mouse), bound in tmux’s root table so they need no
prefix, and inert without set -g mouse on. Each row is a click on one of the
#[range=user|tma:…] segments tma status prints.
| click | does |
|---|---|
| left-click the blocked count | tma jump --blocked: go to the longest-blocked agent. |
| left-click any other tma count | Open the picker popup, the same one prefix a opens. |
| right-click any tma segment | tma jump --menu: a tmux menu of every agent. |
| left-click outside a tma segment | tmux’s own switch-client -t=, so clicking a window name still switches to it. |
| right-click outside a tma segment | Nothing. Alt-right-click still opens tmux’s own window menu. |
Four bindings carry all of that: MouseDown1Status, MouseDown1StatusRight,
MouseDown3Status, and MouseDown3StatusRight. The left-click chain matches in
the order listed above, first match wins.
There is no click that dismisses the popup the counts open. tmux drops every
mouse event that lands outside an open display-popup, so a second click on the
status line never reaches a binding — Esc closes it.
Keys inside the picker
The picker’s own keys, once it is open. The pane you opened it from is never in the list, so jumping to where you already are is not offered.
| key | does |
|---|---|
enter | Jump to the highlighted agent, clear its attention flag, close the picker. |
tab | Open the tmux action menu for the highlighted agent. |
ctrl-s | Toggle the scope between every session and the invoking one. |
↑ / ↓ | Move the selection (wraps at both ends). |
backspace | Delete the last query character. |
| any printable character | Append to the fuzzy query. |
esc, ctrl-c | Close. |
Every printable key types, with none held back for a shortcut — an agent called
auth and a branch called 2fa both have to be searchable, so the action menu
sits on tab and there is no digit quick-select.
The mouse works inside the popup too, with set -g mouse on (no install-keys
needed — the surface asks the terminal for reports itself):
| gesture | does |
|---|---|
| move the pointer over a row | Underline it: what a click would take. |
| click a row | Select it, the same as moving the highlight there with ↑/↓. |
| click the selected row again | Jump to it and close, the same as enter. Two clicks, so a stray one cannot move your client. |
| wheel up / down | Move the selection three rows, stopping at each end rather than wrapping. |
Hover is an underline and selection is the reversed block, because they are different claims: the pointer’s and the keyboard’s. Any keypress drops the hover so only one row is marked, and the next pointer move brings it back.
A press anywhere else in the popup (the border, the preview half, the query line) does nothing.
The live preview needs a popup at least 76 columns wide, the same threshold tma watch uses. Narrower than that, the list takes the whole popup and nothing is
captured.
Keys inside tma watch
| key | does |
|---|---|
enter | Jump to the highlighted agent and clear its attention flag. A plain watcher stays open; the prefix G temporary watcher exits. |
a, tab | Open the tmux action menu for the highlighted agent. (tab is the picker’s spelling; both work here.) |
p | Swap the live preview for the full-width status table, and back. Wide body only. |
g | Flatten the repo grouping, and regroup. Wide body only. |
k / j, ↑ / ↓ | Move the selection (wraps at both ends). |
q, esc, ctrl-c | Quit. |
Both p and g change the wide body, which the pane gets at 76 columns or more.
Below that the body is a single flat list and neither key changes what you see.
Note that a targets the pane under the cursor, not the pane tma watch itself
runs in, which is what lets a screenful of blocked agents be answered from one
place. See Author a custom action.
tma watch takes the same mouse gestures as the picker (hover underlines, click
selects, click again jumps — with the same persistent/temporary behavior as
enter; wheel moves three rows, and any key drops the hover). Group headers are not
selectable, so hovering or clicking a ▸ repo line does nothing.
A plain tma watch is persistent: put it in a split, window, or second terminal
and a jump leaves it running there. The managed prefix G placement is
deliberately different. It opens a dedicated temporary session, records the pane
where you pressed G as the return-trail origin, and destroys the temporary
session when you jump or quit. No dashboard window is left behind in your work
session.
While tma watch is running, that pane’s mouse belongs to tma: tmux’s own
drag-to-select and scroll-into-copy-mode do not apply inside it (hold shift for
your terminal’s native selection). Every other pane is untouched — tmux routes a
mouse event by where the pointer is, and only the pane under it decides.
Keys inside tma jump --menu
The menu is tmux’s own display-menu, so tmux owns the keys.
| key | does |
|---|---|
1-9 | Fire the nth entry. The tenth and later carry no digit. |
↑ / ↓, enter | Move and fire. |
q, esc | Dismiss. |
tma act --menu renders the same way, over the actions fireable on the target
pane.
Jump directions
Which tma jump flag each key runs, and the one with no key.
| flag | key | does |
|---|---|---|
--attention | prefix j | The next agent that wants you: blocked first, then finished-unreviewed. |
--blocked | prefix g, left-click the blocked count | The longest-blocked agent. |
--next | none | The next agent after the current pane, in session then window then pane order. This is the default when no direction flag is given. |
--back | prefix b | One step back along the return trail. |
--home | prefix h | The trail’s oldest origin, clearing the trail. |
--pane <ID> | none | A named pane. What a menu entry and the picker’s Enter both run. |
--menu | right-click any tma segment | A tmux menu of every agent. |
Configuration
tma reads an optional TOML config. Every setting has a working default, so
zero-config works: an absent or partial file yields exactly the defaults shown
below. Unknown keys are a loud parse error (a typo’d table fails every
subcommand rather than being silently ignored), so this page lists the full set.
File location and precedence
The config path is resolved in this order, highest first:
--config <path>- the
TMA_CONFIGenvironment variable $XDG_CONFIG_HOME/tma/config.toml~/.config/tma/config.toml
An absent file at every source is the zero-config floor (all defaults). A present but malformed file fails loudly, naming the file and the offending key, rather than falling back to defaults.
One-shot surfaces (status, ls, event, jump, doctor) read the file once
per invocation, so they always reflect the current file. The daemon re-reads
config and manifests on SIGHUP (or tma reload), and the picker re-reads on its
refresh tick; an invalid reloaded file is kept-old, never fatal.
Full example (built-in defaults)
The values shown are the defaults. Copy any subset; omitted sections and keys keep their defaults.
[fold] # state-machine tuning (seconds)
dwell_secs = 3 # anti-flicker dwell before a working->idle drop
hook_decay_secs = 60 # how long a hook claim outweighs screen evidence
blocked_decay_secs = 300 # the same window for a blocked claim (a prompt sits silent)
freshness_secs = 3 # stamp-freshness window
[status] # `tma status` glyphs + colors; partial entries keep other defaults
blocked = { glyph = "⚑", color = "red" }
working = { glyph = "●", color = "yellow" }
idle = { glyph = "○", color = "green" }
done = { glyph = "✓", color = "magenta" } # idle pane still flagged for attention
unknown = { glyph = "?", color = "colour244" }
[picker] # ratatui picker glyphs + colors; same shape as [status]
unknown = { glyph = "?", color = "darkgray" }
[notify] # notifications
from_event = false # daemonless direct-fire opt-in
# command = "my-notify-hook" # optional notification hook command
on = ["blocked"] # transitions that fire; add "done" for working->idle completions
bell = false # also ring the firing pane's terminal bell
osc = false # also post an OSC 9 desktop notification to the pane's tty
osc_777 = false # also post the OSC 777 form (Ghostty, WezTerm) beside the OSC 9 one
osc_progress = false # show OSC 9;4 taskbar progress while a window has a working agent
# log = "~/.local/state/tma/notifications.jsonl" # append one JSON line per fired notification
# context_high = { threshold = 75 } # also fire once when a pane's context crosses this percent
# stall = { threshold_s = 900 } # also fire once when a pane has been working this long
# blocked = { command = "..." } # per-trigger routing; unset falls back to `command`
# done = { command = "..." }
[act] # the action broker's audit record
# log = "~/.local/state/tma/acts.jsonl" # append one JSON line per fired action
[serve] # `tma serve`: what one remote connection costs and how fresh it is
reconcile_interval_ms = 2000 # stream cadence, and the freshness number the handshake quotes
max_connections = 4 # concurrent serve connections; the next one is refused, not starved
[focus] # attention-clear posture
events = false # set true to also install a pane-focus-in hook (needs `focus-events on`)
[install] # what install-hooks writes into your agent configs
wrapper_ref = "bare" # writes just `tma-hook`, resolved off $PATH; "absolute" writes the path
[hooks] # what an installed hook does at fire time, beyond stamping the pane
claude_reply_lane = { hold_ms = 25000 } # let `tma act` answer claude's permission prompt; false is off
[tmux] # which tmux-compatible binary tma spawns
# bin = "tmate" # default: plain `tmux` off PATH; env TMA_TMUX_BIN overrides this
[telemetry.windows] # model names tma doctor recognizes; sizes parse but are ignored
# "gemini-2.5-pro" = 1048576 # shipped defaults seed only the raw-token agents that need a table
[daemon] # tier-3 daemon cadences
sweep_secs = 45 # reconciliation-sweep cadence
quiet_ms = 1000 # per-pane active->quiet capture trigger
zero_member_recheck_secs = 1 # clientless liveness recheck
demote_edges = 5 # hook-liveness demotion threshold
autostart = false # auto-start the daemon on first use of a surface
restart_on_upgrade = true # let a newer tma replace an older resident daemon
# window_names = { format = "{repo}:{state}" } # rename each window after the agents in it
[[agent]] # per-agent overrides (repeatable)
name = "claude"
enabled = true
process_names = ["claude"] # extra launcher basenames to match
[fold]: detection tuning
State-machine tuning, in seconds. These feed the pure fold that resolves evidence into a verdict.
| key | default | meaning |
|---|---|---|
dwell_secs | 3 | Anti-flicker dwell before a working-to-idle drop is published. |
hook_decay_secs | 60 | How long a working or idle hook claim outweighs later screen evidence. |
blocked_decay_secs | 300 | The same window for a blocked hook claim. Longer because a permission prompt sits silent for minutes; only positive contrary chrome on a screen that can see blocked may expire it. |
freshness_secs | 3 | Stamp-freshness window: a stamp older than this is stale. |
[status] and [picker]: glyphs and colors
[status] styles the tma status one-liner; [picker] styles the fuzzy
picker. Both take one entry per state class: blocked, working, idle,
done, and unknown. Each entry is a { glyph, color } table; a partial entry
keeps the other class defaults.
glyphis the character rendered for that class.coloris a tmux color string: a name (red), an indexed color (colour244/color12), or hex (#ff8800). For[status]it is embedded verbatim in#[fg=...]and validated by tmux.
The done class is an idle pane whose output is still unreviewed (it carries
@agent_attention); its underlying @agent_state token stays idle, only the
surface split changes.
[notify]: notifications
| key | default | meaning |
|---|---|---|
from_event | false | Opt in to daemonless direct-fire from tma event. |
command | unset | Optional notification hook command. Receives the notify payload on stdin (see Pane options and JSON contracts). |
on | ["blocked"] | Which transitions fire a notification. Add "done" for working-to-idle completions. |
bell | false | Also ring the firing pane’s terminal bell. |
osc | false | Also write an OSC 9 desktop notification (<agent> <state>) to the firing pane’s tty. Off by default because emulator support varies; it crosses ssh/mosh/tmate, since the emulator at your end renders it. See Set up notifications. |
osc_777 | false | Also write the OSC 777 form of the same notification (ESC ] 777 ; notify ; <agent> ; <state> BEL) to the firing pane’s tty, beside the OSC 9 one. Ghostty and WezTerm honour 777 and ignore 9, so turning both on covers either emulator; one that reads both would show two banners. |
osc_progress | false | Emit the OSC 9;4 taskbar/tab progress indicator on a window’s working edges: indeterminate when a window’s rollup gains its first working pane, cleared when its last one leaves. Ghostty, WezTerm and Windows Terminal draw it on the tab, so it survives a minimized window. Daemon-only, and written once per edge, never per poll cycle. |
log | unset | Path to a JSONL file; every fired notification appends one line (the hook payload plus an at epoch). ~ is expanded and parent directories are created; the file is created 0600. Errors are silent, never failing a hook. |
include_title | false | Send the pane title to the notify carriers. Off by default: a pane title routinely holds a branch name, a repo path or a prompt fragment, and command pipes the payload to whatever you configured (ntfy, Pushover, a Shortcut), so the title would reach that service’s operator. Turning it on restores the payload’s title key, the TMA_TITLE variable and the audit line’s title together. The host-local display-message always shows the title and is unaffected. |
blocked | unset | A sub-table routing the blocked trigger: { command = "..." }. |
done | unset | The same for the done trigger. |
context_high | unset | A sub-table { threshold = <percent>, command = "..." }. When present, fire once when a pane’s context utilization crosses threshold; naming the sub-table is the opt-in, so threshold is required (command is not). Unset means no context notifications. |
stall | unset | A sub-table { threshold_s = <seconds>, command = "..." }. When present, fire once when a pane has been continuously working for threshold_s seconds; naming the sub-table is the opt-in, so threshold_s is required (command is not) and 0 is refused. Unset means no stall notifications. |
context_high and stall are separate from on because each rides its own armed
flag (@agent_context_notified_at, @agent_stall_notified_at), not the state
lane’s marker. context_high fires once on the crossing, holds while the gauge
stays high, and rearms only after the gauge dips below threshold - 10. stall
measures now - @agent_since on every poll, fires once per working run, and rearms
when the pane leaves working. See Notify on high
context and Notify on a stalled
agent.
Per-trigger routing
Each trigger may name its own command in a [notify.<trigger>] sub-table. A
trigger with no sub-table (or a sub-table with no command) falls back to the
global notify.command, so routing one trigger elsewhere leaves the others
alone. Here a blocked agent pushes to your phone while completions only append to
a log:
[notify]
on = ["blocked", "done"]
[notify.blocked]
command = "curl -s -d \"$TMA_AGENT blocked in $TMA_LOCATOR\" https://ntfy.sh/your-topic"
[notify.done]
command = "cat >> ~/.local/state/tma/done.jsonl"
An unknown key inside a sub-table is a loud parse error like everywhere else, so
a mistyped override never silently falls back to the global command. The
TMA_NOTIFY_CMD environment variable outranks all of them: when set it replaces
every trigger’s command (it exists so a test or CI run funnels every fire into
one sink).
TMA_NOTIFY_FROM_EVENT is its sibling on the other knob. Whenever it is set at
all it decides from_event on its own: exactly 1 turns the daemonless direct
fire on, and any other value (including the empty string) turns it off, so
exporting TMA_NOTIFY_FROM_EVENT=0 overrides a from_event = true in config.
Only an unset variable leaves the config value in charge. Like TMA_NOTIFY_CMD it
exists so a test or CI run can flip the fire path without writing a config file.
[act]: the action audit log
| key | default | meaning |
|---|---|---|
log | unset | Path to a JSONL file; every tma act fire appends one line, refusals included, naming the surface that asked. ~ is expanded and parent directories are created; the file is created 0600. Errors are silent, never failing an action. Key set and rationale: The act audit log. |
[serve]: remote connections
| key | default | meaning |
|---|---|---|
reconcile_interval_ms | 2000 | How often a subscription publishes, and the freshness threshold the handshake quotes to the device. Floored at 250 ms: a device that asked for zero asked for a spin loop against tmux. |
max_connections | 4 | How many tma serve connections this host answers at once. The next one is refused with a typed too-many-connections error rather than accepted and starved. |
The cap exists because every serve connection runs its own detection cycle: a
capture-pane per agent pane and its guarded stamp writes, per connection, per
interval. Two devices plus the daemon is three concurrent detection loops, so
connections cost tmux query throughput and nothing else bounds them. Four is
chosen for a phone and a tablet with room for a re-dial that has not hung up yet.
Pairing and scopes are not config: they live in devices.toml beside this file
and are written only by tma device.
[focus]: attention-clear posture
| key | default | meaning |
|---|---|---|
events | false | Set true to also install a pane-focus-in hook. Requires tmux focus-events on. |
[install]: how agent configs name the wrapper
| key | default | meaning |
|---|---|---|
wrapper_ref | "bare" | "bare" writes the name tma-hook into each agent config and lets the agent resolve it off $PATH. "absolute" writes the wrapper’s full path. |
"bare" is the default because it fails loudly. One string, tma-hook, is
correct on every machine, so a ~/.claude/settings.json synced between a Mac and
a Linux box works on both. Its failure mode is that the wrapper’s directory has to
be on the $PATH each agent inherits, and that failure is caught at the one moment
there is a person to tell: tma install-hooks refuses to wire anything when
tma-hook is not findable, and tma doctor reports whether $PATH still answers
it:
wrapper: tma-hook ✓ on $PATH (/home/you/.local/bin/tma-hook)
"absolute" fails silently by comparison. /Users/you/.local/bin/tma-hook on
macOS is /home/you/.local/bin/tma-hook on Linux, and a synced config carrying the
wrong one produces no error anywhere: the wrapper simply never runs, and hooks
never fire. Choose it when an agent is launched with a $PATH you cannot widen:
a GUI-launched editor (Cursor started from the dock) inherits the desktop
session’s $PATH, not your login shell’s.
When it is chosen, "absolute" writes one path that is not always the wrapper’s
own: when the wrapper lives in a package store (/nix/store, /gnu/store), the
config gets the stable path that reaches it instead, your profile’s bin, found
by walking $PATH for a tma-hook outside the store that resolves to the same
file. A store path names one build and is deleted with it, so writing one would
break your hooks at the next upgrade. tma doctor prints the reference first and
the file it points at in parentheses.
A $HOME-relative string is not offered, because it would only work for half the
agents. Three of the six wiring mechanisms spawn the wrapper as argv with no shell
involved (Codex’s notify array, the OpenCode plugin’s and pi’s spawn), and
those would pass $HOME/.local/bin/tma-hook through as a literal filename. A bare
name is resolved by all six, since execvp searches $PATH exactly like a shell
does.
Set the key before installing, or pass --wrapper-ref bare / --wrapper-ref absolute for one run.
Switching between the two
Wiring already installed under the other posture keeps working, and --check and
tma doctor say so: drift is judged by what a reference RESOLVES to, not by how it
is spelled. An absolute path and the bare name that finds the same file are the
same wiring, so an install made before the default changed does not start
reporting as stale. Only a reference that resolves to a different file, or to
nothing at all, is drift.
Run tma install-hooks --all when you want the configs rewritten to the posture
you chose. One cost is worth knowing before you do: codex pins its hooks.json
trust to the exact command string (trusted_hash per entry in
~/.codex/config.toml), so rewriting those entries makes them inert until you
open codex, run /hooks, and trust them again. Codex’s notify channel and every
other agent are unaffected.
[hooks]: what an installed hook does at fire time
| key | default | meaning |
|---|---|---|
claude_reply_lane | { hold_ms = 25000 } | Claude’s PermissionRequest hook parks the pending call and waits up to hold_ms for a decision from tma act instead of returning immediately. Absent means the default hold; false turns the lane off; a sub-table { hold_ms = <milliseconds> } sets the hold. |
[hooks]
claude_reply_lane = false # off: the hook stamps and returns, with nothing on stdout
The hold is on by default because it costs the pane nothing. Claude draws its dialog
the moment it asks and does not wait for the hook, and a keyboard answer during the
hold is honoured in about 50 ms with the hook still parked (measured on Claude Code
2.1.261). What the hold adds is a second way to answer; it takes none away. The
residual is one sleeping tma event per hand-answered prompt, for up to hold_ms.
Turning it off with false is the behaviour every release before the lane had: the
hook writes its stamps (blocked / permission, the pending-call trio), exits 0
with nothing on stdout, and claude draws the prompt it always drew.
hold_ms is bounded at load into 1000..=590000; a value outside that fails the
config with an error naming the range, rather than loading a lane that quietly never
works. The ceiling is ten seconds under claude’s own 600-second default hook
timeout, so tma is always the side that stops waiting first: a longer hold would end
with claude killing its own hook mid-flight, which is a worse outcome than the hook
returning empty-handed. The floor is there because a hold under a second cannot
outlast the round trip it exists to wait for.
This is its own section rather than a key under [install] because the two are read
at different times by different processes. [install] decides how an agent config
NAMES the wrapper, and tma install-hooks reads it once, at install. This one is
read by the hook itself on every fire, so changing it takes effect on the next
permission prompt with nothing to reinstall.
The lane, and the recipe for answering over it, are in Answer Claude’s prompts over the hook lane.
[tmux]: which tmux binary to spawn
| key | default | meaning |
|---|---|---|
bin | tmux | The tmux-compatible binary every spawn uses: a PATH name (tmate), or a path (/opt/homebrew/bin/tmux) — anything containing a / is used as-is. |
Precedence, highest first: the TMA_TMUX_BIN environment variable, then this
key, then plain tmux. The env wins so one shell can be pointed at another tmux
without editing config:
TMA_TMUX_BIN=tmate tma --socket-path /tmp/tmate-501/default ls
A configured binary that does not resolve is reported as the same
not-installed error an absent tmux gives, naming what to install — never a
per-spawn “no such file”.
When you need this. A tmux client only talks to a server built from the same
protocol version. Point tma at a tmate socket with the ordinary tmux client and
every command fails with a protocol-version mismatch; tma reports that as its own
error naming this key, rather than passing tmux’s terse line through:
$ tma --socket-path /tmp/tmate-501/default ls
tma: tmux protocol version mismatch: the `tmux` client and this server were built
from different versions (are you inside tmate, or is a second tmux first on PATH?);
point tma at the matching client with `[tmux] bin` in config.toml or TMA_TMUX_BIN
The fix is to spawn tmate’s own client (bin = "tmate"). The same applies to a
second tmux from another package manager sitting first on PATH — name the one
that matches your server. Setting bin covers control mode too, so the daemon
attaches with the same client everything else spawns.
[daemon]: tier-3 daemon cadences
The daemon is strictly additive; these knobs apply only when it runs.
| key | default | meaning |
|---|---|---|
sweep_secs | 45 | Reconciliation-sweep cadence. |
quiet_ms | 1000 | Per-pane active-to-quiet capture trigger, in milliseconds. |
zero_member_recheck_secs | 1 | Clientless-session liveness recheck cadence. |
demote_edges | 5 | Hook-liveness demotion threshold: activity edges the pane’s hooks do not account for before its coverage is treated as suspect. An edge landing on a fresh hook claim does not count, and neither does one on a pane whose hooks last said working and have not yet been contradicted by capture, so a single long tool call cannot demote a healthy pane. |
autostart | false | Auto-start the daemon on first use of a surface (ls/status/jump/picker/watch/wait/subscribe). |
restart_on_upgrade | true | Replace a resident daemon whose build is strictly older than the binary running the check. Runs from every user surface, from tma event, and from tma daemon --ensure. Set false to opt out. |
window_names | unset | A sub-table { format = "..." } that renames each tmux window after the agents in it. Naming the sub-table is the opt-in; format is optional and defaults to "{repo}:{state}". See window_names below. |
window_names
Rename each tmux window after the agents running in it. Off until you name the sub-table:
[daemon.window_names]
format = "{repo}:{state}"
| token | expands to |
|---|---|
{agent} | the agent name (claude, codex, …) |
{state} | the window’s highest-attention state: blocked, working, idle, unknown |
{detail} | the winning pane’s @agent_detail (permission, plan, …), empty when it has none |
{repo} | the repo the winning pane’s working directory belongs to |
{branch} | that checkout’s branch |
Any other token is a config error naming the token, so a typo fails the load
instead of ending up in every window name. A token that resolves to nothing takes
its separator with it: with {repo}:{state}, a pane outside a checkout reads
working, not :working. Names are stripped of control bytes and capped at 64
characters.
Only the daemon renames, and only windows holding at least one agent pane. The recipe and the restore rules are in Name windows after their agents.
restart_on_upgrade
A daemon keeps the detection code it started with, so after upgrading tma the
one already running is still the old build until something replaces it. Nothing
about a package upgrade touches a resident process, so without this the daemon
serving your tmux server can be days behind the CLI you are typing.
On by default. The check runs before every user-invoked surface
(ls/status/jump/picker/watch/wait/subscribe), from tma event (the
hook path), and from tma daemon --ensure, so an upgrade is picked up on your
next command rather than at the next tmux server restart. It does not need
autostart, and it is not affected by it.
It only ever REPLACES. With no daemon running it does nothing: starting one
unasked is autostart’s job, and that is still off by default. Opt out with:
[daemon]
restart_on_upgrade = false
tma daemon --restart remains the on-demand, direction-free version (it is how a
deliberate downgrade is served).
The rule is deliberately one-directional, which is what makes it safe to leave on.
Strictly newer replaces older.
Equal never restarts, and an older tma never touches a newer daemon — that is
the direction of skew the wire protocol tolerates anyway (a capability the old
peer does not know is a discriminant it rejects cleanly). Because the relation is
strict, no two builds can ever replace each other, so two tma installs sharing
one tmux server cannot take turns evicting each other’s daemon.
Three further conditions have to hold, all of them fail-safe:
- Both versions must parse as
MAJOR.MINOR.PATCH. Anything else never restarts. - The pid in the lock file must still be alive. A lock file keeps its body after the daemon exits, and a dead pid is nothing to replace.
- No automatic restart may have fired for this server in the last 60 seconds. The
version rule cannot loop, but a new build whose daemon will not stay up can
flap; this bounds that to once a minute.
tma daemon --restartis never subject to it.
A restart costs about 35 ms with nothing listening and a couple of seconds where
the socket is bound but the daemon is still running its control-mode probe.
Nothing is lost across either: a hook that cannot reach a daemon stamps the pane
itself, tma wait degrades to polling, and notification de-duplication lives in
a pane option that outlives the process.
The common case, where the versions already match, costs one small file read and one liveness probe per command. That is the whole price of leaving it on.
[telemetry.windows]: recognized model names
A set of model names tma doctor recognizes. Nothing else reads it.
The table was originally a model -> window size lookup for a telemetry channel
that reported raw token counts with no window of its own. No shipped channel
does: Claude precomputes its context percent, Codex carries
model_context_window in its rollout, and pi and Cursor each send the window
their payload’s own numbers are divided by. A channel with no usable window
stamps nothing rather than guessing one, so no gauge has ever been sized here.
What is left is name recognition, and only where it could matter. tma doctor
consults this table for a pane whose [telemetry.context] channel does NOT carry
a window of its own; for the four that do (claude-statusline-json,
codex-rollout-jsonl, pi-context-json, cursor-statusline-json, so every agent
tma ships), it reports the stamped @agent_model and says nothing about the
table. Where the lookup does apply, a model no entry names is reported as
unrecognized: a label, not a warning, and it does not affect
doctor --exit-code. Adding an entry only quiets that line.
Three gemini-* names ship as recognized, left over from the sizing era; your
entries add to them. The TOML shape is unchanged and the sizes still have to
parse, so an existing config keeps loading; the numbers are ignored.
[telemetry.windows]
"gpt-5-codex" = 272000 # any number; only the name is read
[[agent]]: per-agent overrides
A repeatable table for enabling or disabling a bundled or user manifest, and for extending a manifest’s identity match with extra launcher basenames. This is the supported extension surface for adjusting a shipped agent; adding a brand-new agent is a manifest (see Manifest schema).
| key | default | meaning |
|---|---|---|
name | (required) | The agent name this override applies to. |
enabled | true | Set false to disable detection for this agent. |
process_names | [] | Extra #{pane_current_command} basenames to match, added to the manifest’s own list (so a wrapper binary or renamed build is recognized as this agent). |
Arbitrary custom hook-to-state mapping is not a config surface: the process-name extension above is the configuration extension point, and a hook map belongs in a manifest.
[api.<name>]: per-agent API endpoint
A fallback server base URL for the action broker’s API lane (the manifest side of
that lane is the
[api] transport).
A table keyed by agent name, separate from the [[agent]] override array above.
Only used when the pane carries no plugin-stamped
@agent_api_endpoint — for OpenCode the plugin normally stamps it, so this is the
manual override for a non-standard opencode serve address.
[api.opencode]
api_base = "http://127.0.0.1:4096" # answer permission prompts against this server
| key | default | meaning |
|---|---|---|
api_base | (none) | The http://host:port base the broker POSTs a permission-reply to when the pane has no stamped endpoint. |
Pane options and JSON contracts
tma keeps all shared state in tmux pane options (user options, pane-scoped). There is no socket and none
is needed: any tmux show-options -p or #{@agent_state} format string reads
the current verdict straight from tmux’s own option store, and tma ls --json
gives the same data as a stable structured document. This page is the contract
for both: the pane-option schema and the JSON schemas.
The stamp grammar
Option values are machine tokens, never glyphs. State is one of the closed
vocabulary idle, working, blocked, unknown. Glyph and color rendering
happens only in the surfaces, and is configurable.
@agent_detail is a lowercase machine token ([a-z0-9_-]) qualifying why the
pane is in that state. Unlike state, this vocabulary is open and unstable
until 1.0: an agent manifest may emit a token this engine has never heard of,
and it round-trips intact rather than erroring. Read it defensively — match the
tokens you know and degrade gracefully on the rest. What the bundled manifests
emit today:
| token | state | meaning |
|---|---|---|
permission | blocked | a tool-use permission prompt: approving grants the one action in front of the user |
plan | blocked | a plan-approval dialog. Its affirmative option grants every following action, so tma act approve deliberately does not resolve here |
trust | blocked | a workspace-trust gate. Its affirmative option grants the whole folder, so neither approve nor deny resolves here |
question | blocked | the agent asked you something and is waiting on the answer (OpenCode’s question tool). There is nothing to grant, so approve and deny do not resolve here either: answering means picking one of the options on screen |
rate_limit | working or blocked | a usage-limit wait. The state is the whole point of the pair: working/rate_limit is the agent waiting out its own limit and resuming by itself, blocked/rate_limit is a wait that halted and needs you (a keypress, or a fresh prompt). A wait --until blocked that also reads the detail can tell “needs the clock” from “needs permission” |
tma-core additionally declares error, background and compacting as
constants; no bundled manifest emits those three yet.
Every @agent_*_at value is epoch milliseconds (13 digits today), not
seconds. Millisecond resolution is what keeps two episodes opening in the same
wall-clock second distinguishable.
@agent_attention is a presence flag: the literal value 1 when set, and the
option is absent otherwise. It is compared against @agent_since by the
ordered-input clear, which is why the raise instant has to stay write-once.
Writers order a chained stamp so @agent_stamped_at is written last; a reader
that sees stamped_at older than since or evidence_at caught a chained write
mid-flight and should treat the tuple as in-progress.
One exception keeps that rule from latching. A chained stamp commits in
milliseconds, so a since more than 2 seconds ahead of stamped_at is not a
write in flight: it is a backward wall-clock step (a suspend, an NTP
correction) that stranded the write-once since in the future. Such a tuple
reads as settled, and the next publish rewrites since rather than holding it,
so a stepped clock costs one stale transition time instead of a pane that
re-captures every cycle for the rest of the session.
Pane-option schema
The store carries provenance (@agent_source, @agent_evidence_at) so a
stateless producer can rank a stamped hook claim above its own fresh capture.
Pane-scoped options describe one agent pane; window-scoped and server-scoped
options carry rollups and hints.
| option | scope | semantics |
|---|---|---|
@agent_name, @agent_pid | pane | identity (pid: process-group leader found by the walk) |
@agent_state, @agent_detail | pane | the verdict (state) and its detail token. A tool other than tma that writes these on the same pane implements the @agent_state contract, which is what the two producers have to agree on |
@agent_source | pane | provenance of the current state: hook / capture / process. activity is a legacy value still accepted on read; nothing produces it any more (a viewport hash change stopped being state evidence) |
@agent_evidence_at | pane | epoch ms of the evidence behind the current state |
@agent_since | pane | epoch ms of the state transition, written once by the first producer to record it and never rewritten while state is unchanged (the one exception is a value stranded ahead of @agent_stamped_at by a backward clock step) |
@agent_stamped_at | pane | per-pane freshness marker, written last in a chained stamp |
@agent_hash | pane | scheduling only, and its value is not interpreted: a hash of the last captured viewport whose PRESENCE means “this pane’s screen has been read at least once”, which is all any reader checks before reusing a stored stamp. Nothing compares two hashes, a changed one makes no claim about state, and the algorithm is not part of this contract. Treat it as a boolean; absent on a pane detected purely from hooks |
@agent_attention | pane | presentation flag: value 1 when set, option absent otherwise. Cleared by the focus hooks (the pane arrived at, the pane departed) and by the poll cycle when a client displaying the pane was typed into after @agent_since |
@agent_notified_at | pane | notification-episode marker, written only by the notifier |
@agent_turn_at | pane | epoch ms of the last hook event that meant “a turn ended” and raised the done marker (Stop, codex’s notify, pi’s agent_settled). It exists because @agent_since is write-once per state run and so cannot move when a second completion lands on a pane that never left idle; the notify dedup and wait --since compare against the later of the two. Written only by the hook intake, and only when the marker was down, so one turn end reported on two channels records one turn. Absent on a pane that has never had one, which leaves both comparisons reading @agent_since alone |
@agent_session | pane | owning agent session id from hook registration; the subagent guard compares incoming event session ids against this |
@agent_transcript | pane | path to the agent’s own transcript file, as the hook payload’s transcript_path named it (Claude Code, Codex, Gemini and Cursor carry one; OpenCode and pi do not, and a pane detected from the screen alone never gets one). Stamped beside @agent_session under the same guard, rewritten only when a payload names a different file, never cleared on an ordinary edge, and removed with the rest of the tuple on session end. Claude’s SubagentStop agent_transcript_path is deliberately not read: one session spawns many subagents and one option cannot hold them |
@agent_subagents | pane | space-separated live subagent session ids; SubagentStart appends, SubagentStop removes; bookkeeping only, never top-level state. While it is non-empty, only an event whose session matches @agent_session may write state; an event that cannot be attributed (either side missing) is ignored |
@agent_context_pct | pane | context-utilization metric percent (integer 0–100), or absent when the agent has no telemetry coverage or the channel reported no window (a null-clear); written only by the context intake under the evidence-time write guard, never part of the state tuple |
@agent_context_at | pane | epoch ms of the evidence behind @agent_context_pct; written last in the context mini-chain and advanced even by a null-clear, so a reordered stale push cannot walk the gauge backward (not older acceptance) |
@agent_tokens | pane | tokens currently in the agent’s context window: the absolute @agent_context_pct is a percent of, written in the same guarded chain. Absent for an agent whose channel reports no count tma can call a footprint (see Agent coverage) and cleared by any observation that carries none, so a stale count never sits beside a fresh gauge. A level, never a cumulative spend: tma stamps no usage totals, and the one cost it does stamp (@agent_cost_usd) is the vendor’s own live figure for the current session |
@agent_tokens_at | pane | epoch ms of the evidence behind @agent_tokens, set and cleared with it under the same guard. It equals @agent_context_at whenever a count is present — one observation stamps both — and exists so a reader that wants only the count can age it without reading the gauge’s marker |
@agent_context_notified_at | pane | the context_high notify marker: a present/absent armed flag (absent = armed, present = already fired), never the state lane’s @agent_notified_at; its value is an epoch ms for debuggability only, not a comparison basis. Written only by the context-high notifier, guarded set-from-absent so concurrent firers resolve to one bell; cleared (rearmed) when the gauge dips below threshold - 10 |
@agent_stall_notified_at | pane | the stall notify marker, the same present/absent armed flag as @agent_context_notified_at and just as separate from the state lane’s @agent_notified_at: absent = armed, present = already fired for the current working run. Its value is the epoch ms of the fire, written by the stall notifier under the same guarded set-from-absent; cleared (rearmed) once the pane leaves working |
@agent_quota_pct | pane | account rate-limit utilization percent (integer 0–100) for the window closest to exhausted, or absent when the channel reported no rate_limits block. Account-wide, not per-pane: every pane signed into the same account carries the same figure, so several rows showing it is correct and adding them up means nothing. Written by the same intake that stamps @agent_context_pct, on its own guarded chain |
@agent_quota_window | pane | which window @agent_quota_pct measures: 5h / 7d / spend (Claude) or primary / secondary (Codex). Highest percent wins, and a tie takes the shorter window; without this token the percent is unreadable, since 80% of five hours and 80% of a week are different facts. Set and cleared with the percent |
@agent_quota_resets_at | pane | epoch ms at which @agent_quota_window resets, absent when the channel stated none. Both vendors publish seconds (Claude resets_at, Codex resets_at, or an older relative resets_in_seconds); the conversion happens in the parser, never in a consumer, so this option is ms like every other instant here |
@agent_quota_at | pane | epoch ms of the evidence behind the quota trio and @agent_cost_usd; written last in the quota mini-chain and the not older arbitration basis, exactly as @agent_context_at is for the gauge. Its own marker, so a quiet context gauge never gates a fresh quota push |
@agent_cost_usd | pane | the agent’s own reported cost for the CURRENT session, a string with two decimals (3.50). Absent for a channel that publishes none (Codex’s rollout carries no cost). It is the vendor’s live estimate for one session, not a total tma computed and not a price table; tma reports which pane, right now and aggregates nothing across sessions or over time (ccusage is what answers “how much since Monday”) |
@agent_model | pane | best-effort model-name label the context intake reads from the payload it already has: the Codex rollout window’s model record, or the Claude statusline’s model.id (an object, so the registration path’s top-level-string read cannot reach it); never load-bearing for a gauge, it only feeds tma doctor’s recognized-model line (a model no [telemetry.windows] entry names). Plain-set, cleared on deregister, absent when no model record sat in the tail |
@agent_permission_request | pane | the id of the permission decision the pane is waiting on. Two producers write it: OpenCode’s plugin publishes the server’s own request id on a permission.asked edge (ownership-filtered against @agent_session), and a claude pane whose hook reply lane is on mints one in the PermissionRequest hook, the same 16 hex digits @agent_pending_call carries, since claude’s payload names no request of its own. Cleared on the edges that end the prompt (a working/idle transition, or a permission.replied); the action broker reads it to answer an [api] permission-reply op or to find the parked hook request, and an empty value refuses the API op requires-unmet. The broker also clears it itself once its own reply lands (a 2xx, or a written hook verdict), so a spent id does not read as a pending request until the agent’s next event; it leaves the option alone on a 404, which may already name a newer request |
@agent_question_request | pane | the id of the mid-turn question the pane is waiting on: OpenCode’s que_*, published by its plugin on a question.asked edge (ownership-filtered against @agent_session) and cleared on the edges that end it (a working/idle transition, or a question.replied / question.answered / question.rejected). Its own option rather than a second use of @agent_permission_request, because the two are different channels with different endpoints: a permission-reply POSTed at a question id would quote a request the server is not holding, and the reverse. The broker reads it to answer a question-reply or question-reject op, refuses requires-unmet on an empty value, and clears it itself once its own 2xx lands |
@agent_pending_tool, @agent_pending_call | pane | the tool name and call id of the permission decision a blocked pane is waiting on, stamped from Claude Code’s PermissionRequest hook (tool_name / tool_use_id). Set together with @agent_pending_summary and cleared together on every edge that ends the prompt: the pane leaving blocked (a PostToolUse/Stop working-or-idle stamp), and SessionEnd, which removes the whole tuple. Absent on an agent with no such hook. On a build whose payload omits tool_use_id (Claude Code 2.1.261 does, while its PreToolUse and PostToolUse for the same call still send one) the call id is minted instead: 16 hex digits over the session id, the prompt id, the tool name and the tool input, so it stays the same across repeated fires of one call and differs for the next call. A minted id is a value to compare, not a handle to give back to the agent |
@agent_pending_summary | pane | a one-line summary of that call, derived from the hook’s tool_input: the command for Bash, the file path for Edit/Write/Read, otherwise the first string-valued field. Agent-supplied text, capped at 120 bytes with control characters stripped and a … marking a truncation. Treat it the way you treat a pane title: it is not a machine token, and tma deliberately keeps it out of the notification payload, the notify audit line, and every TMA_* env var, so a summary can never reach a [notify] command sink or a third-party push carrier. It is here and on the JSON rows, both of which stay on your machine |
@agent_api_endpoint | pane | the OpenCode server base URL, stamped at registration by the plugin from its serving address; the broker’s permission-reply endpoint, with a [api.opencode] api_base config fallback (neither present refuses requires-unmet) |
@agent_ignore | pane | you set this one. Any non-empty value takes the pane out of detection: no identity, no capture, no row, and a stamp left from before it was set is cleared on the next cycle. tmux set-option -p @agent_ignore 1 in the pane (add -t <pane> from elsewhere), tmux set-option -pu @agent_ignore to undo. tma never writes or clears it; tma doctor lists every pane carrying it |
@agent_mute_until | pane | notification mute deadline in epoch ms: while it is ahead of the clock the pane fires no notification of any kind (state triggers and context_high alike), and every other lane is untouched — it is still detected, stamped, and counted. tma mute --for 30m writes now + the window, a bare tma mute writes the far-future sentinel 99999999999999 (indefinite), and tma mute --clear unsets it. Living in the store is what makes a mute survive a tma or daemon restart |
@agent_action | pane | single-flight action lock: value <expiry_ms>:<nonce>:<pid>:<name>, acquired and reclaimed by a server-side conditional write on the leading expiry, released nonce-conditionally, self-healing via the embedded expiry; written only by the action broker |
@agent_act_repeat | pane | consecutive-fire run for the act audit log: value <episode_ms>:<action>:<count>, written by the action broker under the held @agent_action lock on the path that is about to have an effect. A new episode or a different action restarts the run; the third consecutive fire warns on stderr and lands as repeat in the act audit log. Never read by the gate |
@agent_summary | window | rollup, a pure function of the sibling panes’ options; space-separated <state>:<count> in the fixed order blocked working idle unknown, zero-count states omitted, empty or absent when the window has no agents (e.g. blocked:1 working:2) |
@agent_session_summary | session | the same rollup grammar over every agent pane in the session, written by the same writers under the same guards. A distinct key rather than @agent_summary at session scope because a pane-context format read falls back pane → window → session: one shared name would make an agentless window render its session’s counts |
@tma_last_poll | server | hint only; the per-pane @agent_stamped_at is authoritative for freshness |
@tma_watch_pid | pane (on the watcher’s own pane) | the focus-nudge target; tma watch advertises its pid here on its own $TMUX_PANE at startup and unsets it on every quit path, so it dies with the pane |
@tma_window_name_orig | window | the window’s name before [daemon] window_names first renamed it. Present exactly while tma owns the name, and its presence is what licenses the restore |
@tma_window_autorename_orig | window | the window-scope automatic-rename saved beside it, since rename-window turns that option off. The sentinel - records “was not set at window scope”, so the restore unsets rather than pinning an inherited value |
@tma_window_name_last | window | the last name tma wrote. A current #{window_name} that differs means you renamed the window yourself, and tma stops renaming it until the next restore |
Values that depend on a previous value (the write-once @agent_since, the
notification dedup, the hook-versus-capture arbitration) are governed by
server-side conditional writes (set-option -pF), which expand formats in the
target pane’s context atomically at write time. Everything else is
last-writer-wins over deterministic values, which converges.
Reading these from your own bar, prompt, or script is a supported first-class use; Read agent state from a status bar or script covers the read forms and the freshness rule that goes with them.
tma ls --json
A versioned, additive-only document. The top level is { "schema": 1, "agents": [ ... ] }; each element of agents is one agent row with this exact key set:
| key | type | meaning |
|---|---|---|
pane | string | tmux pane id (e.g. %5) |
agent | string | agent name |
state | string | idle / working / blocked / unknown |
detail | string or null | detail token, null when none. Open vocabulary — read defensively |
since | number | epoch ms of the last state transition (original unsuffixed key, kept for compatibility) |
since_ms | number | the same value as since; names the unit, preferred in new consumers |
episode_ms | number | epoch ms of the instant wait --since compares against: the later of since_ms and @agent_turn_at. Equal to since_ms until a second completion lands on a pane that never left idle, and the value a supervisor loop feeds back as its next --since (see Drive a supervisor loop) |
locator | string | session:window.pane |
title | string | pane title |
attention | boolean | true when the pane still carries @agent_attention |
done | boolean | true when the pane is idle and carries @agent_attention: finished with output nobody has reviewed |
session | string or null | owning agent session id, null when the pane never registered one |
transcript | string or null | path to the agent’s transcript file (@agent_transcript), null when no hook payload ever named one |
permission_request | string or null | the id of the permission decision the pane is waiting on (@agent_permission_request), null when none is outstanding or nothing on that pane publishes an id (OpenCode’s plugin does; a claude pane does while its hook reply lane is on). A consumer answering the prompt must quote this id, but a match is necessary and not sufficient: it is no proof the request is still open, since the agent’s next event and tma’s own successful reply both clear the option |
stamped_at_ms | number or null | epoch ms of this pane’s last stamp (@agent_stamped_at), the freshness anchor for the whole tuple: compare it against your own clock to decide whether the row is stale before acting on it. null for a pane nothing has stamped yet |
context | number or null | context-utilization percent (0–100), null when the agent has no telemetry coverage or the channel reported no window |
context_at_ms | number or null | epoch ms of the evidence behind context, null when context is |
muted | boolean | true when the pane’s @agent_mute_until is still ahead of the clock, so its notifications are suppressed (tma mute). A resolved boolean, not the deadline: the row is rendered at a known instant, and this is the only question a consumer asks |
tokens | number or null | tokens currently in the context window (the absolute context is a percent of), null when the agent’s channel reports no count tma can call a footprint. Aged by context_at_ms, which is its evidence time too. Never a spend total |
quota | object or null | the account rate-limit reading: { pct, window, resets_at_ms }. null when the pane’s channel reports no rate_limits block (API-key auth, or before the agent’s first API response). pct is 0–100 for the window closest to exhausted, window names which one (5h / 7d / spend / primary / secondary, read defensively, the vocabulary grows with the channels tma parses), and resets_at_ms is epoch ms or null. Nested rather than three flat keys because the three are one fact. Account-wide, so identical values on several rows are not duplicates |
cost_usd | number or null | the agent’s own reported cost for this session, two decimals, null when its channel publishes none. A live per-session figure, never a running total across sessions |
repo | string or null | the pane’s git repo name (basename of the git common dir’s parent, so worktrees share their origin’s name), null when the pane’s cwd resolves to no repo |
branch | string or null | the pane’s checked-out branch (the literal HEAD for a detached head), null when repo is |
worktree | boolean or null | false for a resolved main checkout, true for a linked worktree, null exactly when repo is |
pending_tool | string or null | the tool name of the permission decision the pane is waiting on (@agent_pending_tool), null when nothing is pending |
pending_call | string or null | that call’s id, null exactly when pending_tool is (an agent whose hook carries no id stamps "") |
pending_summary | string or null | one line of at most 120 bytes describing the call, … where it was truncated, null exactly when pending_tool is. Agent-supplied text (a command line, a path): treat it as you treat title. It is deliberately absent from the notification payload, the notify audit line, and every TMA_* env var, so it never leaves the machine through a [notify] command sink |
server | string | the tmux server this row was observed on: its own #{socket_path} (e.g. /private/tmp/tmux-501/default) |
host | string | the hostname of the machine that observed it |
The “done” surface is state == "idle" and attention == true; done carries
that conjunction precomputed from the one definition the whole tool shares (it is
also what wait --until done and --state done mean), so consumers stop
re-deriving it. The state token itself is never mangled — it stays idle. All of
attention, done, session, transcript, permission_request, stamped_at_ms,
context, context_at_ms, muted, tokens,
quota, cost_usd, repo, branch, worktree, pending_tool, pending_call,
pending_summary, server, and host are additive, so the schema stays 1 (a new key never
bumps it, including the keys nested inside quota); render an absent context, tokens, quota
or cost_usd as absence (no gauge, no count, no quota, no cost), never as 0. The
repo/branch/worktree
keys are best-effort: the resolver memoizes one bounded git call per unique cwd
and degrades every field to null on any failure, so a consumer treats them as
hints, never guarantees.
The row writer has a second, title-free protocol surface for consumers that are
not on this machine, and title is the only key that differs between the two: a
pane title is agent-supplied text, kept off a remote surface for the reason
pending_summary is kept out of the notification payload. Both surfaces are
written by one function, so every other key, its order, and its null handling are
the same on both, and a key added to either is added to both. Nothing tma ships
today emits the protocol form; tma ls --json, tma wait --json and tma subscribe are the local surface and carry title exactly as documented above.
server and host: merging rows from more than one place
A pane id is unique within one tmux server and nothing more. Collect
tma ls --json from your laptop and from a build box and both sets will contain a
%5, with no way to tell them apart — the same is true of two servers on one
machine (tmux -L work and tmux -L scratch number panes independently). The
server/host pair is what makes a merged set addressable: (host, server, pane)
is the key you want, and either alone is not enough.
server is the server’s own #{socket_path}, which is what the daemon already
keys its per-server socket and lock on, so it is stable for the life of the
server and identifies it however you addressed it: --socket-name work,
--socket-path /tmp/tmux-501/work, and an invocation from inside that server all
report the same value. The path rather than a hash of it, because an operator
reading a merged log can tell /private/tmp/tmux-501/default from a tmate socket
at a glance.
Both are resolved once per invocation — one tmux call and one uname — and
repeated onto every row, since a line-oriented consumer that filters agents
down to one element must not lose the provenance with it. A long-lived
tma subscribe resolves them once for the life of the stream.
tma wait --json
The single matched agent row as one schema-1 object: the top-level schema key
plus the same row fields as an ls --json element (pane, agent, state,
detail, since, since_ms, episode_ms, locator, title, attention, done,
session, transcript, permission_request, stamped_at_ms, context,
context_at_ms, muted, tokens, quota, cost_usd, repo, branch,
worktree, pending_tool, pending_call, pending_summary, server, host). It shares the
serialization with ls --json, so the two can
never disagree on keys, order, or null handling.
The fleet targets satisfy a SET of panes, so wait --all --json and wait --count <n> --json emit the ls --json document instead — { "schema": 1, "agents": [ ... ] }, one element per satisfied row, with those same row keys. A
consumer parses one shape whether it listed or waited; the single-object form
stays the single-pane targets’ emission (--pane, --agent, --any).
Notification hook payload
When a [notify] command fires, it receives one JSON object on stdin. It
carries metadata only, never captured screen content. The exact top-level key
set:
| key | type | meaning |
|---|---|---|
schema | number | payload schema version (2); kept the first key so a reader sees it up front |
agent | string | agent name |
pane | string | tmux pane id |
state | string | the landed state (blocked, or idle for a completion) |
detail | string or null | detail token, null when none. Open vocabulary — read defensively |
session | string or null | owning agent session id, null when none |
locator | string | session:window.pane |
title | string | pane title. Absent unless [notify] include_title = true — see below |
repo | string | repo name resolved from the pane’s working directory, "" when it is not a checkout |
branch | string | branch name (the literal HEAD when detached), "" when unresolved |
since_ms | number | age of the episode when the notification fired (now - episode_ms); a hook’s own direct fire reads 0, the daemon’s reads its dispatch latency |
episode_ms | number | epoch ms of the episode this fire belongs to: max(@agent_since, @agent_turn_at), the same instant the row’s episode_ms reports. Absolute, so two fires for one episode carry the same value and a sink can collapse on it (apns-collapse-id and friends); since_ms is the age of this instant and cannot be compared for equality against a stored stamp |
context_pct | number or null | the pane’s stored context-window utilization percent, null when the agent reports none |
The pane title is not sent
The pane title is the one field whose content the pane’s own program controls,
and it routinely holds a branch name, a repo path or a prompt fragment.
notify.command pipes this payload to whatever you configured — ntfy, Pushover,
an Apple Shortcut — so the title would reach that service’s operator on every
fire. Since schema 2 it is omitted by default.
[notify] include_title = true puts it back, and it governs all three carriers
together: the payload’s title key, the TMA_TITLE environment variable, and
the notify.log audit line. The host-local display-message baseline is not a
carrier and always shows the title.
The standing rule for this payload: no field enters it that is not safe world-readable. Its writer is also the audit log’s writer, and that log is the file most likely to end up pasted into an issue.
The same values are also exported as environment variables (TMA_AGENT,
TMA_PANE, TMA_STATE, TMA_LOCATOR, TMA_SINCE_MS, TMA_EPISODE_MS, plus
TMA_TITLE when include_title is on,
TMA_DETAIL, TMA_SESSION, TMA_REPO, TMA_BRANCH and TMA_CONTEXT_PCT when
they have a value), so a hook reads whichever is more convenient. Unlike the
JSON, a value with nothing to report is an unset variable rather than an empty
string.
The daemon and the daemonless tma event path build this payload through one
shared builder, so the same transition yields the same object either way.
The [notify] log file (see Configuration)
holds the same object per line, with one extra key: at, the fire time in epoch
milliseconds, written directly after schema. A detached action’s completion is
logged the same way, as its own payload (below) plus at; a completion line
carries action and outcome where a state line carries state, which is how a
reader tells the two apart.
tma act JSON result
The result of firing one action, a schema-1 object with this exact key set. See
tma act for the verb and its exit codes.
| key | type | meaning |
|---|---|---|
schema | number | payload schema version (1) |
action | string | the action name |
pane | string | the target pane id |
outcome | string | the closed outcome token (below) |
exit_code | number | the process exit code this outcome maps to; for an exited outcome it is the exec child’s own code |
reason | string or null | the refusal reason token when outcome is refused, which target went away when outcome is vanished, null otherwise |
cached | boolean | present only with --slot: false on the dispatch that fired, true when this is a replay of that slot’s stored receipt and nothing was sent |
tma act --all --json wraps those same objects: { "schema": 1, "results": [ ... ] }, one element per resolved target in the order they were fired, each
with the exact key set above. The envelope appears whenever --all is passed,
even for a single matched pane, so a consumer’s parse never depends on the match
count. The process exit code is the worst element’s (see
Fan-out); the per-element exit_code stays each target’s
own.
outcome is authoritative and closed: sent (keys delivered), replied (an
API-channel answer delivered over HTTP, a 2xx), exited (a synchronous exec
child finished; exit_code is its code), spawned (a detached supervisor
launched), timeout (a synchronous child killed at timeout_ms), refused
(reason carries which gate), vanished (tmux reports the pane gone, or an
API target answered/withdrawn between gate and act — a 404; reason carries
which), error (broker runtime failure: an unreachable API server, or a tmux
command the server refused, whose stderr rides in the message). reason is one
of gated, requires-unmet, wrong-agent, no-coverage, episode-changed,
request-gone (all exit 4) and locked (exit 5). On a vanished outcome
(both exit 3) it is instead pane-gone (tmux says the pane is gone) or
request-gone (the API server answered 404: the permission request was
already answered or withdrawn, on a pane that is still there).
A --slot dispatch carries one additive key, cached, and the replay’s object
is the fire’s object with cached flipped: same outcome, same reason, same
exit_code, which is the point of a receipt. An error replayed from the ledger
reads reason fired-unknown, a token that appears on no live fire: the broker
could not prove the keystroke did not land, so the ledger records the doubt
rather than inviting a second one. The ledger and its own document are in
tma receipts.
episode-changed and request-gone are the binder refusals: the fire carried
--expect-episode-ms or --expect-permission-request and the pane, read under
the action lock, no longer matches what the caller saw (see Binding a dispatch
to the pane you saw). Note that
request-gone is deliberately one token across two outcomes: refused means
the pane stopped carrying the id the caller quoted, vanished means the server
answered 404 for it. Read outcome to tell them apart.
tma act list document
A schema-1 document enumerating the loaded actions from tma act --list --json:
{ "schema": 1, "actions": [ ... ] }. Each action carries this exact key set, and with --pane two more.
| key | type | meaning |
|---|---|---|
name | string | the action name (also its file stem) |
label | string | the human label |
kind | string | keys, text, or exec |
agents | array of string | the agents this action applies to (empty means all, for an exec action). For a keys action this is the union of its [keys] and [api] transport agents (no per-transport surface in v1: a deck does not care how the answer travels), and for a text action its [text] agents |
when | object or null | the gate, or null when the action is always fireable for its agents |
fireable | boolean | present only with --pane: whether the action can fire on that pane right now |
reason | string or null | present only with --pane: the refusal reason token when not fireable, null when fireable |
The when object carries state (array of state tokens), detail (array of
detail tokens), context_pct_min, and context_pct_max (number or null each).
Detached-action completion payload
A detached (detach = true) action fires one completion notification through the
[notify] command when its child exits. It is its own contract, distinct from the
notification hook payload (a completion has no
state, and its pane may already be gone). The exact top-level key set:
| key | type | meaning |
|---|---|---|
schema | number | payload schema version (1) |
action | string | the action name |
pane | string | the target pane id |
agent | string | the agent name |
outcome | string | the outcome token (exited / timeout / error) |
exit_code | number or null | the child’s exit code for exited, null for a deadline kill or spawn failure |
locator | string or null | session:window.pane, null when the pane is already gone |
lock_release_failed | boolean | present only as true, when the supervisor’s nonce-conditional clear of @agent_action failed; absent on the ordinary release. Additive, so the schema stays 1. A dead pane’s failing option write correlates with a null locator |
The same values reach the command as environment variables: TMA_ACTION,
TMA_PANE, TMA_AGENT, TMA_OUTCOME, plus TMA_EXIT_CODE and TMA_LOCATOR
when they have a value. As on the state path, a value with nothing to report is an
unset variable rather than an empty string, so ${TMA_EXIT_CODE:-} is how a hook
tells a deadline kill from a child that exited. There is no env mirror of
lock_release_failed; read it from the JSON.
A completion rides the same sinks a state notification does: the display-message
baseline, the opted-in bell/osc tty sinks, and the [notify] log audit line.
A hook that cannot start, or that exits non-zero, updates the same failure marker
tma doctor reports.
tma doctor --json
The whole diagnosis as one schema-1 object. It is grouped rather than flat: each
check is its own sub-object, so a consumer reads .daemon.alive rather than
guessing which prefix belongs to what. Top level:
| key | type | meaning |
|---|---|---|
schema | number | payload schema version (1) |
daemon | object | alive (boolean), socket (string, null when the server was unreachable and no socket could be keyed), version (string or null), version_matches (boolean, null when there is no reported version to compare against this build) |
ambient_driver | object | polling (boolean: the server option @tma_last_poll carries a non-zero timestamp), last_poll_age_ms (number, null when it does not) |
clients | object | attached (number of attached clients) |
status_option | object | enabled (boolean: the server’s global status) |
mouse | object | bindings_installed (boolean), enabled (boolean: the server’s mouse option). Both true is the working state; installed-without-mouse is the warning |
watch | object | running (boolean), watchers (number of panes advertising @tma_watch_pid) |
wrapper | object | path (string), present (boolean) for the tma-hook wrapper |
notify | object | last_failure: null, or an object of at (epoch ms), reason, command |
tmux_hooks | array | one object per checked hook: hook (name), present (boolean), hook_state (present / drifted / wiped / missing) |
manifests | object | ok (number loaded) and issues, an array of { file, problem } |
process_name_issues | array | { agent, name, comm_max } per process_names entry past the truncation width |
process_walk | object | ok (boolean: the ps walk ran) and error (string or null). With ok: false the agents array holds only panes a hook registered |
nested_multiplexers | array | { pane, locator, command } per pane running an inner multiplexer client |
remote_panes | array | { pane, locator, command, stamped } per pane behind a remote shell; stamped says whether it still carries a held @agent_* stamp |
ignored_panes | array | { pane, locator, value } per pane carrying @agent_ignore, with the value you set |
stamp_issues | array | { pane, locator, problem } per pane carrying an @agent_* option that does not decode, or an @agent_state no tma write produced (outside the closed set, or set with no @agent_stamped_at) |
agents | array | one object per agent pane (below) |
actions | object | ok (number loaded) and issues, an array of { file, problem } |
Each agents element carries this exact key set:
| key | type | meaning |
|---|---|---|
pane, agent, locator | string | the pane, its agent name, and session:window.pane |
state | string or null | the stamped state token, null when the pane has none |
source | string or null | provenance of that state (hook / capture / process; activity is legacy, read-only) |
evidence_age_ms | number or null | age of the evidence behind it |
hook_status | string | wired, incomplete, not_installed, hookless, or no_adapter. Wiring reached through another program’s config (codex’s notify chained onward) reports wired; the chain is named in the text report |
hooks_wired | boolean | true only for wired, so a consumer needs no token table for the common question |
model | string or null | the best-effort @agent_model label |
window_covered | boolean or null | whether [telemetry.windows] names that model; null when there is no model, and also when the pane’s context channel carries its own window (every shipped one does), where that table is never read |
endpoint_ok | boolean or null | whether the pane’s API endpoint answered; null when the agent has no API lane |
hook_demoted | boolean | registered through a hook but currently running on capture evidence: 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 set this |
tier | number | the effective tier (3 / 2 / 1) |
tier_reason | string or null | why it is not higher; null at the top of what its manifest supports |
remote_panes and ignored_panes are additive, so the schema stays 1.
tma debug explain --json
One pane’s identity, rule evaluation, and verdict as a schema-1 object. Absent
optional fields are an explicit null, never dropped.
| key | type | meaning |
|---|---|---|
schema | number | payload schema version (1) |
pane, locator, command, title | string | the pane id, session:window.pane, #{pane_current_command}, and the pane title |
agent | string | the resolved agent name, or unknown |
identity_source | string or null | observed (the process walk found it) or registered (a hook claimed the pane); null when nothing identified it |
out_of_scope | string or null | the foreground command that put the pane out of scope (a remote shell, an inner multiplexer) |
out_of_scope_kind | string or null | which category that command falls under |
registered_behind | string or null | the boundary a live registration outranks: the pane is in scope, but its agent runs where no capture reaches |
registered_behind_kind | string or null | that boundary’s category |
ignored | boolean | the pane carries @agent_ignore, which is why an otherwise recognizable pane reports no agent and no verdict |
foreground_is_agent | boolean | whether the foreground command is the agent itself; false caps every screen verdict at unknown |
scrolled, history_view | boolean | the pane is scrolled back, and the screen is showing history rather than live state |
evidence | array | { source, claim, at, meta } per evidence record the fold saw |
rules | array | { index, matched, state, detail, priority, region, skip_state_update } per screen rule evaluated; empty when no rules ran |
verdict | object or null | the fold’s result, null when nothing was evaluated (an ignored or unidentified pane) |
The verdict object carries state, detail (string or null), action
(publish or hold), may_override, set_attention, episode_reset, and
winning_evidence, itself { source, at, label }.
ignored is additive, so the schema stays 1.
Additive-schema discipline
All the JSON contracts on this page share one rule: additive changes (a new key)
keep the schema at 1; a breaking change (renaming or removing a key) bumps
schema and the exact-key-set drift tests with it. A consumer can branch on
schema rather than guess. Each serialization site has a test pinning its exact
key set, so a silent drift cannot ship.
The @agent_state contract
tma keeps a pane’s agent state in tmux user options on the pane itself. There is
no socket and no privileged writer in the way, so anything that can run
tmux set-option can write those options too. This page is the contract for a
tool other than tma that wants to: what the tokens mean, which options belong to
whom, and how to write them without clobbering a concurrent producer.
A reader needs Pane options and JSON contracts, which documents the whole option set, and Read agent state from a status bar or script for the read forms. This page is the subset a second producer has to get right, plus the rules that only exist because there are two.
The known second writer
getpipher/agent-status, an
extension for the pi agent, writes pane-local @agent_state and a window-scoped
@agent_window_state, with a working/idle vocabulary that is not published
anywhere. Where its tokens fall inside the closed set below, tma reads them as
its own; where they do not, the pane goes silent in every tma surface. Neither
outcome is anybody’s bug: two producers were writing one option with no agreement
about what the values mean, which is what this page ends.
@agent_window_state is outside this contract. tma neither reads nor writes it,
and nothing here says what it should contain. tma’s window-scoped option is
@agent_summary, a rollup with a different grammar and a different owner (below).
The state token set, closed
@agent_state is exactly one lowercase token from this set. It is closed and
frozen: tma will not add a fifth, and a value outside it is not a state tma has
yet to learn, it is a value tma cannot read.
| token | meaning |
|---|---|
idle | prompt shown, nothing running |
working | the agent is processing; the ball is with the agent |
blocked | waiting on a human; the ball is with the person |
unknown | a recognized agent whose evidence is unreadable |
The one question these answer is whose move is it. Everything finer belongs on
the detail axis. A producer that cannot map its own state onto one of the four
writes unknown, never a fifth token.
What an out-of-set value costs is worth stating exactly, because it is silent.
tma decodes a pane’s whole @agent_* tuple in one step, and an unrecognized
@agent_state fails that decode. Every read path then treats the pane as never
stamped: no row in tma ls, no notification, no tma wait match, no count in
either rollup, no jump target. Nothing errors and nothing is logged. tma doctor
is the one surface that names it.
@agent_detail: open, and unstable until 1.0
@agent_detail qualifies why a pane is in its state. Unlike the state token,
this vocabulary is open: a value tma has never seen round-trips intact rather
than failing the decode, and the vocabulary itself is unstable until 1.0. Read it
defensively, matching the tokens you know and degrading on the rest.
What tma emits today. The bundled manifests emit four:
| token | with state | meaning |
|---|---|---|
permission | blocked | a tool-use permission prompt; approving grants the one action in front of the user |
plan | blocked | a plan-approval dialog, whose affirmative option grants every following action |
trust | blocked | a workspace-trust gate, whose affirmative option grants the whole folder |
rate_limit | working or blocked | a usage-limit wait; working when the agent resumes by itself, blocked when the wait halted and needs a person |
tma-core declares four more as constants that no bundled manifest emits yet:
question, error, background, compacting.
The rate_limit pair is the shape of the axis split: the state says who owes the
next move, the detail says why. A producer that has a reason to report puts it
here and leaves the state token alone.
Keep detail tokens to [a-z0-9_-]. A token containing #, {, }, ,,
whitespace or a control byte is written as empty, because those bytes would
corrupt the conditional-write format below.
The two clocks
Both are epoch milliseconds. A non-zero value below 1000000000000 is read
as legacy epoch seconds and scaled on read, which is the only reason a 10-digit
value is tolerated at all.
@agent_since is the instant of the state transition, and it is written once
per state run. While the stored state is unchanged it is held, not rewritten. Two
consumers depend on that: a duration display (“blocked for 4m”), and the
notification dedup, which fires only when @agent_notified_at predates
@agent_since. Bumping since on an unchanged state re-rings every notifier on
the machine. There is one exception, and tma implements it: a since stranded
more than 2000 ms ahead of @agent_stamped_at came from a backward wall-clock
step (a suspend, an NTP correction), and the next publish rewrites it rather than
holding a value in the future forever.
@agent_stamped_at is the instant of this write. Every write tma makes ends
with it, including a refresh that changes nothing else. It is the per-pane
freshness marker: a reader ages the verdict against it, and tma’s poll cycle
compares it against #{window_activity} to decide whether the pane needs
re-reading at all. Because it is written last, a reader that finds
@agent_stamped_at older than @agent_since or @agent_evidence_at caught a
chained write in flight and should treat the tuple as in progress rather than
acting on it.
Identity and provenance
@agent_name is the agent’s name, free text. tma writes the manifest stem
(claude, codex, pi). This is the only option in the contract with an open
vocabulary and no parse, so it is where a second writer says which agent it is
reporting on.
@agent_source is the provenance of the current state, and it is closed:
| token | meaning |
|---|---|
hook | the agent itself reported this, through an event, with no inference |
capture | it was read off the pane’s screen |
process | it came from the process walk |
activity | legacy, accepted on read, produced by nothing since a viewport hash change stopped counting as evidence |
An unrecognized value fails the same tuple decode an unrecognized state does, so
a writer must not put its own name here. @agent_source describes where the
evidence came from, not who wrote the option; the writer’s identity goes in
@agent_name. An absent @agent_source decodes as capture, which is the
weakest provenance and the one tma’s guards will overwrite most readily.
@agent_evidence_at is the epoch ms of the evidence behind the current state, as
distinct from when it was written. Absent reads as 0. It is the basis tma’s
guards arbitrate on, so a producer that leaves it unset is choosing to lose every
arbitration it enters.
@agent_session is the agent’s own session id, as the agent reports it. tma
stamps it from the hook payload at registration. A writer that knows its agent’s
session id should set it: it is what attributes a later event to the right pane,
what the subagent guard compares incoming events against, and the key the Codex
rollout tail discovers its file by. Absent is legal, and costs those three
things.
Writing without clobbering
tmux options have no transactions, no compare-and-set, and no writer identity. A read-then-write from a second process loses exactly the races that matter, because a state change lands inside the read-to-write window. So tma never decides client-side. Every write whose correctness depends on a previous value is a server-side conditional: tmux expands the format in the target pane’s context, at write time, and stores the result.
One set-option -F per option, where the value expands either to the new value
or back to the stored one:
tmux set-option -p -F -t <pane> <key> '#{?<suppress>,#{<key>},<new value>}'
<suppress> is a format that expands truthy when the write must be held. tma
chains every option of the tuple under the same <suppress>, in one tmux
invocation with ; separators, so the tuple commits together or holds together,
and @agent_stamped_at goes last.
Two of tma’s own guards are the ones a second writer meets:
- A capture-sourced write suppresses on
#{==:#{@agent_source},hook}. A screen read never overwrites a claim the agent itself made. - Blocker chrome overrides a
workingoridlehook claim only when the capture postdates the stored evidence:#{&&:#{==:#{@agent_source},hook},#{e|<=:<capture ms>,#{@agent_evidence_at}}}.
The practical consequence for a second writer that reports its agent’s own
events: stamp @agent_source as hook with a fresh @agent_evidence_at, and
tma’s capture writes hold off the pane. Blocker chrome newer than your evidence
still wins, which is deliberate. A blocked pane is the expensive thing to get
wrong.
For your own writes, the rule two event-sourced producers use between themselves is evidence-time arbitration, and it is the one to copy. Suppress when the store holds a hook claim whose evidence is strictly newer than yours, so the outcome depends on when the two things happened rather than on which process finished first:
now=$(...) # epoch milliseconds
suppress="#{&&:#{==:#{@agent_source},hook},#{e|<:$now,#{@agent_evidence_at}}}"
tmux set-option -p -F -t "$pane" @agent_state "#{?$suppress,#{@agent_state},working}" \
\; set-option -p -F -t "$pane" @agent_source "#{?$suppress,#{@agent_source},hook}" \
\; set-option -p -F -t "$pane" @agent_evidence_at "#{?$suppress,#{@agent_evidence_at},$now}" \
\; set-option -p -F -t "$pane" @agent_stamped_at "#{?$suppress,#{@agent_stamped_at},$now}"
One caveat on -F. A tmux before 3.2 accepts the flag and stores the literal
#{?...} string instead of expanding it, which corrupts the tuple rather than
failing loudly. tma probes the behaviour once per server (it writes a format that
can only expand to ok, reads it back, and unsets it) and caches the answer in
the server option @tma_setpf_ok, degrading to plain unguarded writes when the
answer is no. A second writer should run its own probe under its own key; that
one is tma’s.
The rollups are tma’s
Two options carry counts rather than one pane’s state:
| option | scope | grammar |
|---|---|---|
@agent_summary | window | <state>:<count> pairs, space separated, in the fixed order blocked working idle unknown, zero counts omitted (blocked:1 working:2) |
@agent_session_summary | session | the same grammar over every agent pane in the session |
Both are unset when the scope holds no agent. They are a distinct key per scope on purpose: a pane-context format read falls back pane, then window, then session, so one shared name would make an agentless window render its session’s counts.
Do not write either one. They are a pure function of the panes’ own
@agent_state, and tma recomputes both from every pane in scope and writes only
where the recomputed value differs from what is stored. A second writer that
stamps a valid token is therefore counted for free, with no rollup code of its
own. One that stamps an out-of-set token is dropped from the count silently: the
token fails to parse, and the pane contributes nothing to the total.
Removal when the agent exits
A pane outlives its agent, so a stamp nothing refreshes has to go. tma unsets the whole per-pane set in one invocation and recomputes both rollups:
@agent_state, @agent_detail, @agent_source, @agent_evidence_at,
@agent_since, @agent_stamped_at, @agent_attention, @agent_notified_at,
@agent_turn_at, @agent_hash, @agent_pid, @agent_name, @agent_session,
@agent_subagents, @agent_context_pct, @agent_context_at, @agent_tokens,
@agent_tokens_at, @agent_context_notified_at, @agent_quota_pct,
@agent_quota_window, @agent_quota_resets_at, @agent_quota_at,
@agent_cost_usd, @agent_model, @agent_permission_request,
@agent_pending_tool, @agent_pending_call, @agent_pending_summary,
@agent_api_endpoint, and two internal anchors, @tma_title_match_pid and
@tma_reg_dead_since.
Three survive that removal on purpose, and go only in tma uninstall’s sweep:
@agent_action (a detached action can outlive the agent that triggered it),
@agent_mute_until (a mute belongs to the user, not to the episode), and
@tma_watch_pid (its owner is a tma watch, not the agent). @agent_ignore is
never cleared by tma at all: the user wrote it, and only the user takes it back.
A second writer removing its own pane unsets at least @agent_state,
@agent_detail, @agent_source, @agent_evidence_at, @agent_since and
@agent_stamped_at. Leaving @agent_state standing with nothing refreshing it
is the failure this list exists to prevent: no reader can tell a held stamp from
a live one except by its age.
The rules, condensed
A second writer MUST:
- write only
idle,working,blockedorunknowninto@agent_state, andunknownrather than a fifth token when nothing fits; - write
@agent_stamped_aton every write, in epoch milliseconds, last in the chain; - write
@agent_sourcefrom its closed set,hookfor the agent’s own event andcapturefor a screen read, and put its own identity in@agent_nameinstead; - unset the state options when its agent exits.
SHOULD:
- set
@agent_evidence_atto the instant of the evidence rather than of the write, since that is what arbitration compares; - set
@agent_name, and@agent_sessionwhen it knows the agent’s session id; - write conditionally with
set-option -F, holding when the stored evidence is newer than its own; - write
@agent_sinceonly on a state change and hold it otherwise; - keep
@agent_detailto[a-z0-9_-].
MUST NOT:
- write
@agent_summaryor@agent_session_summary; - write any
@tma_*option (@tma_last_poll,@tma_setpf_ok,@tma_watch_pid,@tma_origin_*,@tma_title_match_pid,@tma_reg_dead_since). Those are tma’s internals and carry no compatibility promise; - clear or overwrite another writer’s stamp unconditionally;
- write
@agent_ignore, which is the user’s opt-out and nobody else’s.
Checking the result
tma doctor reports panes whose @agent_* options tma did not write or cannot
decode, naming the pane, the value, and the possibility that a second tool is
writing them. Two shapes reach that line: an @agent_state outside the closed
set, and an @agent_state set with no @agent_stamped_at beside it, which no
tma write can produce. Both count toward tma doctor --exit-code, and both ride
the --json document’s stamp_issues array.
Agent coverage
tma detects an agent’s state from three kinds of evidence: hook events the
agent fires, the pane’s on-screen chrome, and the process running in the pane.
This page documents, per agent, which hook events map to which states, which
states the screen rules cover, and how each agent’s pane is identified.
Every mapping here is verified against real agent behavior, not documentation: each table reflects events actually observed firing with a full payload.
Coverage matrix
| agent | mechanism | hook coverage | context telemetry | notes |
|---|---|---|---|---|
| Claude Code | settings.json hooks block or plugin manifest | working / idle / blocked / lifecycle | statusline push shim: per-turn context_window.used_percentage, parsed by claude-statusline-json (event channel) | The Notification matcher distinguishes permission prompts from idle reminders. tma install-hooks claude writes the block and the statusline context shim. |
| Codex CLI | two channels: a notify program in config.toml (payload as a trailing argv arg) and a Claude-style hooks.json in CODEX_HOME (payload on stdin, real session id) | working / idle / blocked / lifecycle, all hook-covered; blocked, working and idle also screen-carried | rollout token_count file-tail, parsed by codex-rollout-jsonl (file-tail channel): the per-turn token_count event carries total_token_usage + model_context_window, so the percent needs no model table | tma install-hooks codex writes both. The hooks.json entries need one-time in-TUI trust (/hooks) before they fire. Discovery keys the pane’s rollout file off @agent_session; its cross-version stability is the fixture caveat in ACTIONS.md open question 6. |
| OpenCode | JS plugin in ~/.config/opencode/plugin/ | working / idle / blocked / lifecycle (registration only); blocked, working and idle also screen-carried | — | No session-end or subagent hooks; deregistration rides the pid-change / pane-close path. tma install-hooks opencode writes the plugin. Answers approve/deny over its HTTP API rather than keystrokes (API lane, below). |
| Gemini CLI | settings.json hooks object, native event names | working / idle / blocked / lifecycle, hook- and screen-carried | — (turn-granularity token counts only) | Reuses the Claude JSON editor over ~/.gemini/settings.json. Local config is gated behind a per-folder trust prompt. |
| Cursor CLI | user-level ~/.cursor/hooks.json in cursor’s own shape, plus a statusLine shim in ~/.cursor/cli-config.json | working / idle / lifecycle via hooks; blocked screen-only | statusLine push shim: per-turn context_window (total_input_tokens / context_window_size), parsed by cursor-statusline-json (event channel). The statusLine mechanism works but is undocumented (highest churn risk) — a payload change degrades to an absent gauge | Cursor exposes no permission hook, so blocked rides a screen rule. tma install-hooks cursor writes cursor’s own hooks JSON shape and the statusLine context shim. |
| pi | extension module in ~/.pi/agent/extensions/ | working / idle / lifecycle via the extension; no blocked | getContextUsage() push shim: the extension forwards pi’s ctx.getContextUsage() (a precomputed percent + absolute contextWindow) on the turn-settled event, parsed by pi-context-json (event channel) | pi auto-runs tools with no approval state, so there is no blocked signal at all. tma install-hooks pi drops the extension. |
The context-telemetry column records how tma obtains each agent’s context-window
utilization percent (@agent_context_pct), declared per agent by a
[telemetry.context] manifest block naming the channel shape (event /
file-tail / screen) and a compiled-in parser format. Claude Code’s
statusline command receives a per-turn JSON payload; tma install-hooks claude
installs a chaining statusline shim that runs the user’s existing statusline
command unchanged and forwards the payload to tma event --agent claude --kind context --pane "$TMUX_PANE" --payload - fire-and-forget. Codex uses the pull
shape instead: the poll cycle tails the last
64 KiB of the session’s rollout JSONL (discovered from @agent_session), reads
the newest token_count record, and stamps the gauge under the same
evidence-time guard — no shim, no persisted offset. pi uses the push shape like
Claude: its extension API exposes ctx.getContextUsage() directly (a precomputed
percent against pi’s own window, so no window table is needed), which
tma install-hooks pi’s extension forwards on the turn-settled event, parsed by
pi-context-json. Cursor also uses the push shape: its ~/.cursor/cli-config.json
carries a statusLine command that runs per turn with a context_window payload
(total_input_tokens / context_window_size), which tma install-hooks cursor’s
chaining shim forwards to tma event --agent cursor --kind context --pane "$TMUX_PANE" --payload -, parsed by cursor-statusline-json.
Cursor’s statusLine mechanism works but is absent from its documented config
reference, so it is the highest-churn channel: the parser reads only the two
confirmed numeric fields and fails safe (a missing field is ignored, never a wrong
stamp and never a clear), letting a payload change degrade to an absent gauge.
Gemini exposes only turn-granularity token counts (too coarse for a live gauge),
so its row carries no gauge. An agent with no [telemetry.context] block has no
gauge, and a context-gated action (e.g. compact) refuses no-coverage on it
rather than gated.
Absolute token counts
Some of those channels also carry the raw number the percent is made of. Where
that number is unambiguously the tokens currently in the context window, tma
stamps it as @agent_tokens (with @agent_tokens_at, its evidence time) and
emits it as the tokens key on the JSON rows. Where it is not, nothing is
stamped: an absolute under a name that is wrong half the time is worse than no
absolute at all, and the percent is unaffected either way.
| agent | @agent_tokens | why |
|---|---|---|
| pi | yes — context_usage.tokens | the number pi divides by contextWindow to get the percent it sends; a footprint by construction |
| Cursor CLI | yes — context_window.total_input_tokens | the numerator of the percent tma computes, in the same context_window object Claude publishes; Claude’s copy of that object carries used_percentage: 78 beside total_input_tokens: 156000 over a 200000 window, which is what pins the field’s meaning |
| Claude Code | yes, from 2.1.132 — context_window.total_input_tokens | the numerator of the used_percentage Claude computes, in the object Cursor’s row cites. Gated on the payload’s own version because the pre-2.1.132 cumulative-fields bug corrupts the count and the percent together (used_percentage: 247 beside total_input_tokens: 494000), and early in such a session the percent still reads plausible while the count is already wrong. Below the gate, or with no parsable version, the count stays absent and any stored one is cleared |
| Codex CLI | no | total_token_usage mixes the two meanings (below) |
| Gemini CLI, OpenCode | no | no context channel at all |
Codex is the interesting one. Its token_count record carries a
total_token_usage whose terms disagree about what they measure:
input_tokens tracks last_token_usage.input_tokens exactly (the per-request
context sent — a footprint), while output_tokens climbs past the last turn’s
(a session-cumulative counter). Their sum, which the gauge divides by
model_context_window, is therefore a hybrid: dominated by the footprint term,
so the percent is sound, but not a quantity either “tokens in context” or
“tokens spent” describes. Until a live token_count reading settles it
(ACTIONS.md open question 6), Codex panes carry a gauge and no count.
@agent_tokens is a level, not a total, and adding it up across turns means
nothing. tma still computes no usage total and ships no pricing table.
tma also records each pane’s model name in @agent_model, taken from the hook
registration payload where the agent sends one: Claude’s SessionStart, Codex’s
session hooks (a common model input field), and Cursor’s sessionStart each
carry model as a top-level string, stamped last-write-wins on the
registration-class event (a pane’s model changes only via the agent’s own
switcher). Two context channels keep it fresh from a payload they already read:
Codex’s rollout tail from its turn_context record, and Claude’s statusline from
model.id (which is nested in an object, so the registration path’s top-level
read cannot reach it). All of them write the same value and do not fight. Gemini,
OpenCode, and pi send no model in their hook payloads, so their panes carry no
@agent_model. The label feeds tma doctor’s recognized-model line: a model no
[telemetry.windows] entry names is reported, not warned about.
Account quota, and the one cost figure
Beside the per-pane gauge, two channels publish an account-wide rate-limit
reading in a payload tma already receives: Claude’s statusline
rate_limits.{five_hour,seven_day,spend_limit} and Codex’s rollout
rate_limits.{primary,secondary}. tma stamps the window closest to exhausted as
@agent_quota_pct with its @agent_quota_window token, plus
@agent_quota_resets_at where the channel states one. Context is per-pane and a
/compact away from recoverable; the quota is shared by every pane on the
account and is not, which is what makes it the number that decides whether
starting a sixth agent is worth it.
The absence rules are the context lane’s, reused verbatim: a missing
rate_limits block is IGNORED, never treated as a clear. It is absent for
API-key auth, absent before the agent’s first API response, and dropped per
window once that window’s resets_at passes, so a payload without one says
nothing about the account. The stored reading stays and ages via
@agent_quota_at.
Claude also publishes cost.total_cost_usd, which tma stamps as
@agent_cost_usd. This is the one exception to the no-cost posture and it is a
narrow one: the figure is the vendor’s own live estimate for the current
session, stamped as stated and never recomputed. tma reports which pane, right
now. It does not aggregate cost across sessions or over time,
ccusage is the tool that answers “how much since
Monday”. Gemini, OpenCode, pi and Cursor publish no cost figure, so their panes
carry none.
Anthropic’s own note applies to the number and travels with it: on a Max or Pro subscription the session cost “isn’t relevant for billing purposes”, and it is an estimate at list price rather than the bill.
OpenCode API lane
OpenCode answers a pending permission prompt over HTTP rather than by keystroke, so
tma act approve / deny on an OpenCode pane POST the reply instead of sending a
key. The bundled approve/deny actions carry an [api] transport for OpenCode
(op = "permission-reply", reply = once / reject); the keys path is untouched
for every other agent. interrupt stays keys-everywhere.
The paths are the v1 plane, and that is the contract. The /api/** family is a
different set of objects, not a second spelling of these: a v2 permission is created
by a caller rather than raised by the tool loop, and with a real v1 request pending,
/api/permission/request and /api/session/{id}/question answer empty envelopes
while /api/session/{id}/permission/{id} answers 404. So an op pointed at one would
succeed against nothing, and a test written there would pass vacuously.
The op vocabulary
op | takes | endpoint | outcome on 2xx |
|---|---|---|---|
permission-reply | reply = once / always / reject | POST {base}/permission/{id}/reply, {"reply":"<verdict>"} | replied |
question-reply | the caller’s picked labels | POST {base}/question/{id}/reply, {"answers":[["<label>"]]} | replied |
question-reject | nothing | POST {base}/question/{id}/reject, no body | replied |
interrupt | nothing | POST {base}/session/{id}/abort, no body | sent |
replied and sent are a real distinction, not a spelling. A 2xx on a request the
server was holding open is proof it was answered; a 2xx on a command is proof of
delivery and nothing more, so an abort earns the same word a keystroke does. A 404
is vanished with reason request-gone on either.
question_reject is the one bundled action using the question channel:
[api] opencode = { op = "question-reject" }, gated on blocked/question. It ships
with no [keys] arm even for OpenCode, because a keystroke fired at the wrong dialog
row is exactly what the API lane exists to avoid.
There is deliberately no bundled question_reply. Answering means quoting the
option labels the user picked, one list per question, and tma act has no flag that
could carry a list of lists of strings. The op exists and the broker sends it, so a
library caller fires it through broker::fire with FireArgs::answers; a CLI surface
for it awaits a flag design. That is the gap: from a terminal today, a question is
either dismissed with question_reject or answered by typing into the pane.
The captured request/response pair (verified against the @opencode-ai/sdk v2
types shipped with OpenCode 1.18.0), the evidence a new operation needs:
- event —
permission.asked,properties = { id, sessionID, permission, … }; theidis the reply’srequestID. The plugin forwards it asrequest_id(acceptingrequestIDtoo), stamped to@agent_permission_requestunder the session-ownership filter and cleared on the working/idle edge or apermission.repliedevent. - endpoint —
POST {serverUrl}/permission/{requestID}/replywith body{"reply": "once" | "always" | "reject"}, a 2xx on success and 404 once the prompt is answered or withdrawn. The plugin stamps{serverUrl}(from itsPluginInput.serverUrl) to@agent_api_endpointat registration; the server pins its own port, so there is no hardcoded default.[api.opencode] api_baseinconfig.tomlis the fallback when the plugin cannot stamp it.
tma doctor warns on an OpenCode pane that has a pending @agent_permission_request
but no resolvable endpoint.
Agents with partial coverage get hybrid treatment: hook events for what they
report, screen-capture fallback for what they do not. The per-agent manifest
declares which states its hooks cover ([hooks].covers) and which its screen
rules can see ([capture].visible).
Idle screen rules
Every bundled agent ships a positive idle rule, anchored on its composer chrome.
It matters most for a pane nobody wired hooks into: without one, a turn ending
leaves no claim on the screen at all, so the fold holds the previous verdict and
the pane reads working forever.
Two properties are shared by all six and are the reason the rules are safe:
- The composer co-renders with the working chrome. Every one of these anchors is
on screen mid-turn as well — claude’s
⏵⏵mode line sits under the spinner, codex’s›underesc to interrupt, and so on. The fold’s slot order (blocked, then working, then idle) resolves the co-render, so the idle claim only decides anything once the working chrome is gone. idleis deliberately absent from[capture].visiblefor all but claude.visibleis what lets screen evidence expire a contradicting hook claim. Chrome that renders mid-turn is not evidence a turn ended, so listing idle there would let working chrome decay a legitimate idle hook claim. The rule gives the fold a claim; it does not give it authority over a hook.
Where each agent’s working anchor lives
Codex and cursor anchor on a streaming footer, gemini on its own chrome. Claude used to be the
exception: it animated a braille spinner in its OSC title, and the title was cheaper to read than
the screen. That stopped at 2.1.246: the title is now a static ✳ <task> in every state, and
since the ✳ also drives claude’s idle rule, the two states became indistinguishable from the
title alone. Claude now reads the bottommost body row that starts with one of its activity glyphs.
A live spinner such as · Actioning… (4m 16s · ↓ 16.8k tokens) needs an ellipsis and must lack the
textual · done marker; a gerund-less ✻ Waiting for 1 background agent to finish has its own
predicate. A completion below an old spinner therefore stops the working claim, while a newer
spinner below completion history remains working. Orange and gray ANSI colors are not signals:
capture matching strips them, and no-color terminals omit them. The title rule is retained for
older builds, at a lower priority.
This only ever mattered on capture tier. A hook-wired pane reports working directly — but hooks
fire on tool calls, so a long tool-free stretch (extended thinking, a single long response) leaves
capture as the only live evidence, which is where the stale idle showed up.
Hooks always reference a stable wrapper script (tma-hook), never the binary
directly: the wrapper resolves the binary at fire time and exits silently when it
is missing, so rebuilds and moves never surface as hook failures. tma install-hooks <agent> writes the wiring (idempotent, additive, printing a diff
first) and --check verifies it. For the installer’s per-agent caveats
(including the codex and gemini trust gates), see
install-agent-hooks.
Bundled action key sequences
The actions tma ships reach each agent through the tma-tmux write path. Each
key element is one send-keys argument with named-key interpretation on, so
Enter, Escape, and /compact mean what tmux says. An agent with no cell for
an action is not covered by it (the action does not apply to that agent’s panes).
| action | claude | codex | cursor | gemini | opencode | pi |
|---|---|---|---|---|---|---|
approve | 1 | y | y | 1 | API once | n/a |
deny | Escape | Escape | n | 3 | API reject | n/a |
interrupt | Escape | Escape | C-c | Escape | Escape | Escape |
compact | /compact Enter | n/a | n/a | n/a | n/a | n/a |
steer | text + Enter | text + Enter | n/a | n/a | text + Enter | n/a |
steer_now | text + Enter | text + Enter | n/a | n/a | n/a | n/a |
These sequences derive from each agent’s captured prompt chrome (the same
captures the blocked/working screen rules anchor on): approve is the confirm
key of the permission prompt, deny the reject key, interrupt the cancel key
its working screen advertises. Where the option prints its own accelerator that
accelerator wins over the option’s position, because tma’s read path never knows
where a selection cursor is resting: that is why Codex approves with y (from
1. Yes, proceed (y)) rather than Enter, and why Cursor, whose dialog has no
digits at all, uses y and n.
Two shapes are deliberately absent. pi has no permission prompt, so it
carries no approve or deny row in any state; it has interrupt because it
has a working state. The always/session-wide options are never wired (Claude’s
2, Codex’s p, Cursor’s tab and shift+tab): each writes a persistent grant
and approve answers one request. compact stays Claude-only until other
agents’ compact commands are captured.
Cursor’s n registers the rejection and then opens its own “tell the agent what
to do instead” composer, which takes an optional reason and skips on an empty
one. And interrupt does not compose with a queued message everywhere: on Claude
and Codex interrupting submits the queue immediately, but on Gemini it returns
the queued text to the composer unsent.
The sequences are provisional pending per-agent, per-version keystroke fixtures, the same discipline detection rules get (ACTIONS.md open question 2).
The two steering rows are text actions: the keys shown are the manifest’s
wrapping, and the message itself is the caller’s, delivered literally (see
--text). steer fires at an idle pane, steer_now
at a working one, and only for the agents that declared steer_now, meaning
their composer was watched queueing a message typed mid-turn (Claude shows
Press up to edit queued messages, Codex Messages to be submitted after next tool call). Gemini is excluded from both, permanently: it queues a mid-turn
message and then returns it to the composer, unsent, when the turn is
interrupted. OpenCode steers through its pane like the others here; its HTTP API
carries a prompt too, but that lane is not what this action uses.
Per-agent hook mappings
Claude Code mapping
| hook | tma event | state effect |
|---|---|---|
SessionStart | agent-start | pane registered, state idle |
UserPromptSubmit | working | working |
PreToolUse / PostToolUse | working (heartbeat) | working, refreshes liveness |
PermissionRequest | blocked | blocked / permission, the moment the decision is needed |
Notification (permission / idle-prompt) | blocked | blocked / permission (fallback) |
Notification (usage-limit auto-continue) | rate limit | working / rate_limit while it resumes itself, blocked / rate_limit when it halts |
Stop | idle | idle |
SubagentStart / SubagentStop | subagent bookkeeping | append/remove session id in @agent_subagents; never a top-level state change |
SessionEnd | agent-end | pane deregistered, options removed |
PermissionRequest is the claim that matters for blocked. It fires the moment
a tool call needs a decision and carries the pending call in its payload
(tool_name, tool_input, tool_use_id). The hook is deliberately not
installed with async: true, the point is to stamp the pane before the dialog
draws, and a backgrounded hook would race it.
After those stamps the hook mints the pending call’s id onto
@agent_permission_request and holds for up to hold_ms (25 seconds by default)
waiting for a verdict. An approve or deny that lands inside the hold returns
Claude’s own decision object on stdout, so the tool runs or refuses with no
keystroke; a hold that expires prints nothing and leaves the prompt untouched, which
is also what [hooks] claude_reply_lane = false in config.toml gives you on every
prompt. Claude draws its dialog without waiting for the hook either way, so the pane
looks the same and the keyboard still answers it. Claude is the only agent with this
lane, and the lane is the only reason a Claude pane carries
@agent_permission_request at all: its payload names no request of its own, so tma
mints one. The recipe is Answer Claude’s prompts over the hook
lane.
The Notification permission_prompt|elicitation_dialog entry stays as the
fallback for a build without PermissionRequest. On its own it was late: the
vendor docs gate that notification on the prompt having already waited about six
seconds, so a pane read working for those six seconds. The matcher runs as a
regex over the whole raw JSON payload, so it hits whether the discriminator lands
in message or a notification_type field.
Three further Notification types report a claude.ai usage-limit wait (Claude
Code 2.1.234 and later, where automatic continue is on by default):
notification_type | claim | why |
|---|---|---|
quota_auto_resume_fired | working / rate_limit | Claude Code continues the task on its own, at the reset or as soon as credits, an upgrade or a model switch frees usage. Nobody is waiting on you |
quota_auto_resume_stale | blocked / rate_limit | the limit reset while the computer slept for more than about 30 minutes, so Claude Code waits for an Enter keypress instead of continuing |
quota_auto_resume_disabled | blocked / rate_limit | the wait ended without continuing (autoContinueAtUsageLimit off, the reset moved past 24 hours, repeated limit hits, or a blocked continuation). Nothing resumes until you send a prompt |
The installed Notification hook carries no matcher, so every notification type
reaches tma event and the manifest’s matchers are the whole filter. That is
what let these three be mapped without touching an installed config.
Re-verified against Claude Code 2.1.212 (2026-07-29): driving a live
Bash permission prompt fired Notification with notification_type":"permission_prompt"
and message":"Claude needs your permission" — unchanged, so the matcher still
covers the one blocking flow. No new blocking notification_type was observed, so
the matcher is not widened (capture-gated: nothing captured justifies it). The
idle-reminder Notification could not be reproduced — it fires only when a real
terminal loses focus, and a detached scratch pane (no attached client, 15+ min
idle) never triggered it; idle stays driven by the Stop hook regardless, so a
name change there would not affect tma.
OpenCode mapping
A JS plugin in ~/.config/opencode/plugin/ forwards OpenCode’s event-bus events
to tma-hook opencode <token> with the payload on stdin.
| OpenCode event | tma event | state effect |
|---|---|---|
plugin load / session.created | session-start | pane registered, state idle |
session.status = busy / chat.message / tool.execute.before | user-prompt-submit | working |
session.idle / session.status = idle | stop | idle |
permission.asked | permission-required | blocked, detail permission |
question.asked | question-required | blocked, detail question |
blocked and working are visible on screen, so [capture].visible = ["blocked", "working"]. The working anchor is the in-flight status row’s esc interrupt hint,
present for the whole of a live turn and gone the moment it settles; only the text is
matched, since the ■/⬝ progress bar beside it animates. idle is anchored on
ctrl+p commands, the invariant tail of the composer’s status row (the rest of that row
is per-pane: token count, cost, cwd). The permission dialog replaces the composer, so a
blocked screen never raises it.
A second blocked rule reads OpenCode’s question tool, which asks you to pick an option
mid-turn: blocked with detail = question. The turn is technically still in flight, but
nothing advances until somebody answers, so the ball is with the human. It is anchored on
the dialog’s footer, ↑↓ select enter submit esc dismiss, verbatim at every captured
width (60 through 200, opencode 1.18.29). The options render as 1. Red / 2. Blue, the
same numbered shape as claude’s permission dialog, so the numbering alone is deliberately
not matched; the tool’s own 3. Type your own answer line is corroboration rather than a
required token, since it is appended only while the tool’s custom flag is not false.
tma act approve does not answer a question. approve and deny gate on detail = permission, so a question pane offers neither, which is the intended result: for OpenCode
those two POST a permission-reply for a pending request id, and a question is not a
pending permission. question_reject is what answers one, over the API lane above.
It is no longer a screen read only. The plugin forwards question.asked as
question-required, so a pane whose screen tma cannot see, a remote shell, reports the
question too. The que_* id rides that edge as question_id and is stamped to
@agent_question_request under the session-ownership filter, cleared on the working/idle
edge or on question.replied / question.answered / question.rejected. It is a
separate option from @agent_permission_request and carries a separate payload key,
because the two are different channels: a permission reply fired at a question id would
quote a request the server is not holding, and the reverse.
The OSC title is not usable. The original audit found it static (OpenCode); on 1.18.18
it is state-bearing (OC | Running <command>) but goes stale, still reading Running
a minute after the turn settled, which would pin such a pane to working forever. That makes registration the only thing standing between a quiet pane
and unknown, which is why the plugin fires session-start at load and not just on
session.created: OpenCode emits session.created for a brand-new session only, so a
TUI waiting at its prompt and opencode --continue (a restored session) both used to
sit at ? until the first message. The load-time fire carries no session id — the
session.created edge that follows a real new session records it.
permission.updated is accepted as a synonym for permission.asked. The
@opencode-ai/sdk typings shipped alongside 1.18.18 name only the former while the
1.18.18 binary contains only the latter, so the plugin answers to both and a rename
lands inert instead of silently dropping blocked. The question channel is wired the
same way and for the same reason: question.updated is accepted beside
question.asked, and all three of question.replied, question.answered and
question.rejected clear the stamp. The published event schema spells the end
question.replied; a second source spells it question.answered. Rather than pick,
the plugin answers to both, and whichever the shipped binary does not emit is inert.
Two further event-bus signals were captured live (driving opencode serve’s
/event SSE stream through the HTTP API, 2026-07-29) and deliberately left both
observed but not wired:
permission.replied—{sessionID, requestID, reply:"once"|"always"|"reject"}, fires the instant a pendingpermission.askedis answered. It clearsblocked, but it is redundant with tokens the plugin already forwards on the same edge: an approve (once/always) is immediately followed bytool.execute.before(⇒working) and a reject/turn-end bysession.idle(⇒idle). Since the plugin must be live to receivepermission.repliedat all, it is live for those too, so wiring it adds no coverage. Wiring it correctly would also need the reject-vs-approve split, and only theonce(approve) case was captured — so, capture-gated, it stays unwired.session.deleted—{sessionID, info:{…full session record…}}, fires only on an explicit session delete (API/TUI action), not on TUI/process exit (a closed pane’s session persists on disk, undeleted). It is therefore not the session-end signal tma lacks: deregistering on it would remove a still-live pane, and (since tma’s deregister is keyed on the pane, not the session id) a delete of some other background session would wrongly deregister the active one. Real OpenCode session-end continues to ride the pid-change / pane-close path.
Codex mapping
Codex has two mechanisms, both wired by tma install-hooks codex. The notify
program in <CODEX_HOME>/config.toml is spawned on a notification with the JSON
appended as a trailing argv argument (not stdin); it fires only
agent-turn-complete, whose payload carries thread-id/turn-id, not a
session_id.
| Codex notify type | tma event | state effect |
|---|---|---|
agent-turn-complete | notify (matcher agent-turn-complete) | idle |
The Claude-style <CODEX_HOME>/hooks.json (its command must be a string, not
an argv array) delivers one JSON payload on stdin with a real session_id, so
registration and the subagent guard are live here.
| Codex hooks.json event | tma event | state effect |
|---|---|---|
SessionStart | agent-start | pane registered, state idle |
UserPromptSubmit | working | working (fires pre-response, lands even on a failed turn) |
PreToolUse / PostToolUse | working | working |
PermissionRequest | blocked | blocked/permission (payload names the pending tool) |
Stop | idle | idle |
SessionEnd | agent-end | pane deregistered, options removed |
SubagentStart / SubagentStop | subagent bookkeeping | append/remove session id in @agent_subagents |
Combined: [hooks].covers = ["working", "idle", "blocked", "lifecycle"]. Blocked
is also screen-carried for the daemonless, quiet-edge, and untrusted-hook cases,
so codex.toml ships [capture].visible = ["working", "blocked"]. idle has a
screen rule too — the › composer arrow in the last six rows — but stays out of
visible (see “Idle screen rules”, above). The approval dialog numbers its options
with the same arrow, so the rule carries a not leaf excluding › <n>. .
Gemini mapping
A Claude-shape hooks object in ~/.gemini/settings.json, so tma install-hooks gemini reuses the Claude JSON editor unchanged. Payloads arrive on stdin with a
real snake_case session_id; Gemini uses its own native event names.
| Gemini event | tma event | state effect |
|---|---|---|
SessionStart (source = “startup”) | agent-start | pane registered, state idle |
BeforeAgent (prompt) | working | working |
BeforeTool / AfterTool (tool_name/tool_response) | working | working |
AfterAgent (prompt_response/stop_hook_active) | idle | idle (fires last in a turn) |
Notification (notification_type = “ToolPermission”) | blocked | blocked/permission |
SessionEnd (reason = “exit”) | agent-end | pane deregistered, options removed |
SubagentStart / SubagentStop | subagent bookkeeping | wired but inert (no gemini subagent events) |
blocked is gated by the ToolPermission matcher so a future non-permission
notification cannot false-block. Coverage: [hooks].covers = ["working", "idle", "blocked", "lifecycle"] and [capture].visible = ["working", "blocked"]. Idle
rides the AfterAgent hook, and additionally has a screen rule anchored on the
bottom edge of the composer box (▀▀▀…) within a tail_lines(8) window; that
chrome overlaps working, which is why idle has a rule but is not visible (see
“Idle screen rules”, above). The window is the safety here, not the glyph: gemini
echoes each prior user message into the transcript inside an identical box, so the
frame appears on a blocked screen too, but the approval dialog replaces the
composer and the footer, leaving the bottom of the screen empty of box edges.
Cursor mapping
User-level ~/.cursor/hooks.json only (a project .cursor/hooks.json fires
nothing). The shape is cursor’s own, not Claude’s: {"version": 1, "hooks": {"<event>": [{"command": "…"}]}}, so tma install-hooks cursor uses a
dedicated adapter. Payloads arrive on stdin with a real snake_case session_id.
| Cursor event | tma event | state effect |
|---|---|---|
sessionStart (model, is_background_agent) | agent-start | pane registered, state idle |
beforeSubmitPrompt (prompt) | working | working (interactive only; headless takes the prompt from argv) |
preToolUse / postToolUse (tool_name/tool_output) | working | working |
postToolUseFailure (failure_type/error_message/is_interrupt) | working | working (matcher "is_interrupt":false) |
stop (token counts, status) | idle | idle |
sessionEnd (reason/final_status = “completed”) | agent-end | pane deregistered, options removed |
subagentStart / subagentStop | not observed | absent (see below) |
blocked is not hook-covered: Cursor exposes no dedicated permission hook
(beforeShellExecution fires for approved and pending commands alike), so it
rides the approval-dialog screen rule. Coverage: [hooks].covers = ["working", "idle", "lifecycle"] and [capture].visible = ["working", "blocked"]. idle has
a screen rule (outside visible) anchored on the composer’s half-block frame plus
an → row — the frame rather than the hint text, because the hint reads → Plan, search, build anything on a fresh session and → Add a follow-up afterwards, and
because the approval dialog reuses the same arrow glyph but not the frame.
postToolUseFailure (captured 2026-07-29): a cat of a missing file
exited non-zero and fired postToolUseFailure carrying failure_type":"error",
error_message, and is_interrupt":false; the agent recovered and produced its
final answer, so this is a working continuation (the failure sibling of
postToolUse), not a blocked signal. It is wired to working behind the matcher
"is_interrupt":false: the user-abort variant (is_interrupt":true) was not
captured, so it stays unmapped rather than false-stamp working on a turn the
human just stopped (cursor fires no stop on an interrupt, so a wrong working
would linger). The original hypothesis that this event could distinguish a tool
failure from the screen-rule-only blocked inference did not hold: a failed tool
does not block, it continues, so blocked remains screen-carried only.
subagentStart / subagentStop: absent (re-verified 2026-07-29). Cursor
fires no subagent hook even when the model narrates spawning a “background
subagent” — a -p --force prompt asking for a parallel sub-task produced the
narration but no hook. By the Claude precedent (subagent events are
ownership-filtered bookkeeping, never state-driving) this is no coverage loss:
even if captured they would not drive pane state.
Cursor’s context gauge rides a separate file from its hooks: ~/.cursor/cli-config.json
carries a statusLine command ({"type": "command", "command": "…", "padding": N})
that Cursor runs per turn with a JSON payload on stdin whose context_window object
holds total_input_tokens and context_window_size. tma install-hooks cursor
installs a chaining statusLine shim there (like Claude’s, sharing the same machinery):
it runs the user’s existing statusLine command unchanged, preserves sibling keys such
as padding byte-faithfully, and forwards the payload to tma event context. The
cursor-statusline-json parser computes the percent from the two fields with no window
table. This statusLine mechanism works but is absent from Cursor’s documented config
reference (confirmed live 2026-07-29): it is the highest-churn context channel, so a
missing context_window is ignored rather than treated as a clear, and a payload change
degrades to an absent gauge instead of a wrong one.
pi mapping
JS/TS modules auto-discovered from ~/.pi/agent/extensions/ subscribe with
pi.on("<event>", handler). tma install-hooks pi drops a self-contained
extension that shells out to tma-hook pi <event> fire-and-forget, is inert
outside tmux, and never blocks pi. pi’s events carry no session id, so the
extension reads ctx.sessionManager.getSessionId() and forwards {session_id}.
| pi event | tma event | state effect |
|---|---|---|
session_start (reason=“startup”) | agent-start | pane registered, state idle |
before_agent_start (prompt, systemPrompt, …) | working | working |
tool_execution_start (toolName, args) | working | working |
agent_settled (type only) | idle | idle (fires once per turn) |
session_shutdown (reason=“quit”) | agent-end | pane deregistered, options removed |
SubagentStart / SubagentStop | subagent bookkeeping | wired but inert (no pi subagent events) |
blocked is not a pi state: pi auto-runs tools with no per-tool permission
prompt, so there is neither a hook nor a screen rule for it. Coverage:
[hooks].covers = ["working", "idle", "lifecycle"] and [capture].visible = ["working"] (the Working... loader row). idle has a screen rule outside
visible, requiring both a full-width composer rule line in column 0 and the
<pct>%/<window>k context gauge on the status row.
On the turn-settled agent_settled event the extension additionally forwards pi’s
ctx.getContextUsage() to tma event --kind context. pi’s ContextUsage carries
a precomputed percent and an absolute contextWindow (both null right after a
/compact until the next assistant response, and the whole object omitted when no
model/window is available), so the pi-context-json parser reads the percent with
no window table; an unknown window stamps no gauge (fail-safe, not wrong).
Manifest schema
One TOML manifest is the complete description of an agent: how to recognize its
pane, how its hook events map to states, which states its screen rules detect,
and the screen rules themselves. Bundled agents ship as manifests in
crates/tma-core/manifests/; a user manifest in ~/.config/tma/agents/ adds a
new agent or shadows a bundled one by filename stem, with no code change.
State routing is normative and not manifest-overridable: a manifest maps its
agent’s events and screens into the closed state vocabulary (idle, working,
blocked, unknown); it cannot invent or remap a state. [details] carries
token spellings only.
Top level
| field | required | type | meaning |
|---|---|---|---|
min_engine_version | yes | version string | The minimum engine version this manifest needs (e.g. "0.1"). Missing components default to zero. A manifest that needs a newer engine is rejected with an upgrade error, checked before the strict parse so a newer-schema field never surfaces as a confusing “unknown field”. |
[identity] | yes | table | How to recognize the pane. |
[hooks] | no | table | Present marks the agent hook-capable. Absent is the screen-only floor. |
[capture] | yes | table | Which states the screen rules reliably detect. |
[[rules]] | no | array | Screen rules. |
[details] | no | table | Detail-token alternate spellings. |
[telemetry] | no | table | Metric channels the agent exposes. Absent means it exposes none. |
Unknown fields at any level are a parse error.
Manifests load per file. A user manifest that fails to parse (or whose rule
regexes fail to compile) is skipped and the rest of the set still loads, so a typo
in one file never costs you the bundled agents. The poll surfaces print one
tma: skipping manifest <path>: <error> line to stderr; tma event stays silent
(a hook must never speak); the daemon logs it and starts on what loaded; and
tma doctor lists every skipped file with its error under agents:. A bundled
manifest that fails is fatal — that is a build bug, not user input.
[identity]
| field | required | type | meaning |
|---|---|---|---|
process_names | yes | array of string | #{pane_current_command} values that cheaply flag a candidate agent pane. |
title_patterns | no | array of string | Regexes over #{pane_title} that narrow a generic process_names match. When non-empty, a pane is this agent only when a process_names entry matches AND the current title matches one of these patterns (or the flicker-stickiness hold is active). Empty (the default) leaves identity as process match alone. Patterns compile at engine build; an invalid pattern is a build-time error naming the file. |
Comm truncation: why a name may need two spellings
process_names is matched against two different sources, and they do not always
report the same string for the same process:
ps -eo comm, the process-tree walk. This is what decides the pane holds an agent at all. tma takes the first whitespace-separated token and basenames it.#{pane_current_command}, the foreground check. This decidesforeground_is_agent, and a false answer caps every screen verdict atunknown— the pane is still identified, but its capture evidence stops meaning anything.
Keep every entry to 15 characters or fewer, and list both spellings when the
real name is longer. Fifteen is where the truncation lands: the Linux kernel’s
comm field is 16 bytes including the terminator, and macOS’s libproc — which is
where tmux gets #{pane_current_command} — cuts at the same width. On Linux both
sources truncate, so one 15-character entry covers both. On macOS they diverge:
ps reports the invoked path (untruncated, and the symlink you typed), while
tmux reports the resolved binary’s name, truncated.
The bundled codex manifest is the worked example, and the divergence is why it carries two entries:
[identity]
process_names = ["codex", "codex-aarch64-a"]
Homebrew installs codex as codex-aarch64-apple-darwin behind a codex symlink.
Launch it and the two sources disagree, verified on macOS:
$ ps -eo pid,comm | grep codex
13071 /opt/homebrew/bin/codex ← basenames to `codex`: identifies the pane
$ tmux display -p '#{pane_current_command}'
codex-aarch64-a ← 15 chars of the resolved binary
Drop the second entry and codex panes are still found (the walk matches codex)
but every screen rule is capped at unknown, because the foreground check
compares codex-aarch64-a against a list that has no such name.
tma doctor flags the trap directly — an entry longer than 15 characters with no
truncated sibling in the same list:
agents: 6 loaded, no issues
- myagent: process_names entry "my-very-long-agent-binary" is longer than 15 chars, the width both
macOS libproc and the Linux kernel truncate `comm` to, and no truncated spelling sits beside it —
add "my-very-long-ag"
A long entry with its prefix already listed is not flagged: that is the codex shape, and it is correct.
[hooks]
Presence of this block marks the agent hook-capable.
| field | required | type | meaning |
|---|---|---|---|
covers | no | array of token | Which states and lifecycle the agent’s hooks report: any state token, plus the literal lifecycle. This is the first coverage gate. |
[[hooks.map]] | no | array | Event-to-claim mappings. |
[[hooks.map]]
| field | required | type | meaning |
|---|---|---|---|
event | yes | string | Agent hook event name (e.g. Notification, SessionStart). |
matcher | no | string | Optional payload matcher regex (e.g. `permission_prompt |
claim | yes | table | The claim this event raises: either a state claim { state = "...", detail = "..." } (detail optional) or a lifecycle claim { lifecycle = "start" } / { lifecycle = "end" }. |
turn_end | no | bool | Whether this event MEANS a turn ended (false by default). Set it on the agent’s turn-end event and nowhere else. It is a property of the EVENT, not of its claim: the same state = "idle" is raised by screen rules too, where nothing ended. tma raises the done marker on a turn end even when the pane was already idle, which is the only way a second completion is signalled after the user cleared the first marker; an event that merely observes idleness (an idle-reminder notification) must leave it false, or a cleared marker would come straight back. |
[capture]
| field | required | type | meaning |
|---|---|---|---|
visible | no | array of state | The states the agent’s screen rules reliably detect, evidence-backed. This is the second coverage gate that the coverage-aware decay reads. |
[[rules]]
One screen rule. Higher priority wins when multiple rules match.
| field | required | type | meaning |
|---|---|---|---|
state | yes | state | The state this rule asserts on match. |
detail | no | detail token | Detail to attach (e.g. permission for a permission prompt). |
priority | no | integer | Higher wins on multiple matches. Default 0. |
region | yes | string | Where to look (see below). |
match | yes | matcher | The text predicate (see below). |
skip_state_update | no | bool | This screen shows history, not live state: freeze, do not restate. Default false. |
region
| value | meaning |
|---|---|
tail_lines(N) | Match against the last N lines of the captured tail. Bottom-anchored agents use a small window that always fits the visible screen, so this never reads scrollback for them. |
bottom_non_empty_lines(N) | Match against the last N lines that end at the last line with content: trailing blank lines (blank after ANSI stripping) are discarded before the window is taken. Use this instead of tail_lines(N) for an agent that renders inline, where a session that has not yet filled the screen leaves blank rows below its chrome that would consume the whole window. |
visible | Match against the visible screen only: the last #{pane_height} lines of the captured tail, before any further scoping. This removes scrollback lines for agents whose chrome floats in the transcript, so a whole-screen rule cannot match a prior turn’s chrome out of scrollback on a short pane. When the height is unknown it degrades to the whole captured tail. |
title | Match against the pane title. |
match
A screen matcher composes leaf text predicates. TOML is externally tagged:
| form | meaning |
|---|---|
{ contains = "x" } | Substring match. |
{ regex = "..." } | Regex over the region. |
{ line_regex = "..." } | Regex applied per line. |
{ last_matching_line = { selector = { ... }, predicate = { ... } } } | Find the bottommost line matching selector, then apply predicate to that line alone. Added in tma 0.5.11; manifests using it must set min_engine_version = "0.5.11" or newer. |
{ any = [ ... ] } | Any child matches. |
{ all = [ ... ] } | All children match. |
{ not = { ... } } | The child does not match. |
Regex strings are stored verbatim and compiled at match time.
[details]
Maps a canonical detail token to its alternate spellings, so a screen or hook that spells a detail differently still normalizes to the canonical token.
[details]
rate_limit = { aliases = ["ratelimited", "rate-limited"] }
Each key is the canonical token and aliases lists alternate spellings.
[telemetry]
One optional sub-table per metric. Only context exists today; a second metric
would be an additive sibling rather than a rename.
| field | required | type | meaning |
|---|---|---|---|
[telemetry.context] | no | table | How tma obtains this agent’s context-window utilization percent. Absent means the agent has no gauge. |
[telemetry.context]
| field | required | type | meaning |
|---|---|---|---|
channel | yes | token | The transport shape: event (the agent pushes a payload to tma event --kind context), file-tail (tma reads a bounded, end-anchored slice of a file the agent writes), or screen (last-resort extraction). Any other value is a parse error naming the three. |
format | yes | string | The compiled-in parser id, bytes in and metric out (claude-statusline-json, codex-rollout-jsonl, cursor-statusline-json, pi-context-json). |
format is not user-authorable: a new one needs core code, so the loader accepts
any string here and the intake refuses an unknown id at read time rather than
failing the whole manifest. Declaring the block is what separates a gated
refusal for a context-gated action (the channel exists, the metric has not landed
yet) from a permanent no-coverage one. Which agent uses which channel is in
Agent coverage.
Token rules
A detail token (a [details] key, an alias, a [[rules]] detail, or a
[[hooks.map]] claim detail) must be a safe machine token: non-empty and drawn
from lowercase a-z, digits, _, and - only. This rejects the format
metacharacters (#, {, }, ,), whitespace, control bytes, and any non-ASCII
glyph at the load boundary, so a corrupt token can never reach the render chain.
A [details] key that collides with a state token is rejected, because state
routing is normative and not manifest-overridable.
A full manifest
min_engine_version = "0.1"
[identity]
process_names = ["claude"]
[hooks]
covers = ["working", "blocked", "idle", "lifecycle"]
[[hooks.map]]
event = "Notification"
matcher = "permission_prompt|elicitation_dialog"
claim = { state = "blocked", detail = "permission" }
[[hooks.map]]
event = "Stop"
claim = { state = "idle" }
turn_end = true
[[hooks.map]]
event = "SessionStart"
claim = { lifecycle = "start" }
[[hooks.map]]
event = "SessionEnd"
claim = { lifecycle = "end" }
[capture]
visible = ["working", "idle", "blocked"]
[telemetry.context]
channel = "event"
format = "claude-statusline-json"
[[rules]]
state = "blocked"
detail = "permission"
priority = 100
region = "tail_lines(5)"
match = { any = [ { contains = "Do you want to proceed?" }, { regex = "❯\\s" } ] }
[[rules]]
state = "idle"
priority = 10
region = "tail_lines(50)"
skip_state_update = true
match = { all = [ { contains = "transcript" }, { not = { contains = "❯" } } ] }
[details]
rate_limit = { aliases = ["ratelimited", "rate-limited"] }
Action manifest schema
One TOML manifest declares one action: what it fires (keys into the pane, a
text string you supply at the call, or an exec process), which agents and
states it applies to, and how the broker guards
it. Bundled actions ship as manifests in crates/tma-core/actions/; a user
manifest in ~/.config/tma/actions/ adds a new action or shadows a bundled one by
filename stem, with no code change. Fire one with tma act; to
author one, see Author a custom action.
The action name is normative: name must equal the filename stem, so a user file
cannot collide with a bundled action’s name without also shadowing it. Unknown
fields at any level are a parse error, the same discipline as the agent manifest
and config.toml.
Top level
| field | required | type | meaning |
|---|---|---|---|
min_engine_version | yes | version string | The minimum engine version this action needs (e.g. "0.1"). A manifest that needs a newer engine is rejected with an upgrade error. |
name | yes | string | The action name; must equal the filename stem. Invoked as tma act <name>. |
label | yes | string | The human label shown in --list and the menu. |
kind | yes | keys | text | exec | keys sends a guarded key sequence into the pane; text delivers one caller-supplied string literally, wrapped in manifest keys; exec spawns a guarded process with context env. text was added in tma 0.5.13; a manifest using it must set min_engine_version = "0.5.13" or newer. |
when | no | table | The gate. Absent means the action is always fireable for its applicable agents. |
agents | no (exec) | array of string | Which agents an exec action applies to; empty (the default) means all agents. A keys action derives applicability from its [keys] table instead, so this is ignored for keys. |
requires | no | array of token | Context keys that must be non-empty for the gate to pass: session, cwd, pid, title. An unknown token is a parse error. |
confirm | no | bool | Mark the action as wanting a second factor (below). Default false. |
detach | no (exec) | bool | Run an exec action detached under a tma-owned supervisor. Default false. Forbidden for keys. |
timeout_ms | no (exec) | integer | Synchronous exec timeout in milliseconds. Default 30000. |
detach_timeout_ms | no (exec) | integer | Detached exec wall-clock deadline in milliseconds, after which the supervisor kills the process group. Default 900000 (15 minutes). |
command | yes (exec) | string | The exec command, passed to sh -c verbatim with no substitution. Required for exec, forbidden for keys. |
[keys] | keys | table | Per-agent key sequences. Forbidden for exec. A keys action needs at least one entry across [keys], [api] and [hook]. |
[api] | keys | table | Per-agent API-channel transports (below). Forbidden for exec. An agent may appear in [keys] or [api], never both. |
[text] | text | table | Per-agent text transports (below). The only transport table a text action may carry, and forbidden for the other two kinds. |
sigils | no (text) | array of string | Leading characters a text payload may not start with, one character each. Defaults to ["/", "!"]; an explicit [] opts out. text only. |
[hook] | keys | table | Per-agent hook-lane transports (below). Forbidden for exec. An agent may appear in [keys] and [hook] at once; in [api] and [hook] never. |
Structural rules are enforced at parse: kind = "keys" requires at least one
transport entry across [keys], [api] and [hook] (a single-transport action is
legal) and forbids command / detach; kind = "exec" requires command and
forbids all four transport tables; kind = "text" requires at least one [text]
entry and forbids command / detach / agents / [keys] / [api]; [text] and
sigils are rejected on the other two kinds; and an agent named in both [keys]
and [api], or in both [api] and [hook], is a parse error (the broker never
picks between two structured transports at act time, so there is no silent
fallback; a hook-lane miss falls through to keystrokes, never to HTTP).
[when]: the gate
All present keys are ANDed. Any action that reaches the pane with keystrokes
(keys and text) re-verifies a stale state stamp with a fresh detection cycle
before gating.
| field | required | type | meaning |
|---|---|---|---|
state | no | array of state | The states that satisfy the gate: idle, working, blocked, unknown. |
detail | no | array of detail token | Detail tokens that satisfy the gate (e.g. permission). |
context_pct_min | no | integer | Minimum context-utilization percent. Fails closed: an absent metric refuses. |
context_pct_max | no | integer | Maximum context-utilization percent. Fails closed the same way. |
A context bound that reads a metric the agent’s manifest declares no telemetry
channel for refuses permanently with reason no-coverage; a bound whose metric is
merely absent right now refuses with gated (see the reason tokens in
Pane options and JSON contracts).
[keys]: per-agent key sequences
Each key is an agent name and its value is the key sequence for that agent. An
agent with no entry cannot receive the action (that is how a keys action’s
applicability is derived).
Each array element is one tmux send-keys key argument with named-key
interpretation on, so Enter, Escape, C-c, and /compact mean what tmux says
they mean; the whole sequence is delivered in a single send-keys through the
tma-tmux write adapter, with no inter-key delay.
[keys]
claude = ["1"]
codex = ["y"]
[api]: per-agent API-channel transports
Some agents answer a prompt over HTTP instead of via keystrokes. [api] maps an
agent name to a built-in operation the broker delivers with one HTTP POST rather
than a send-keys (OpenCode, whose server answers a pending permission). It is a
transport for the same action, not a new action: approve on a Claude pane sends
keys, on an OpenCode pane it replies over the API, under one name and one gate.
Applicability is the union of [keys] and [api]; an agent in both tables is a
parse error. The operation vocabulary is closed — v1 ships exactly
permission-reply, whose reply is one of once / always / reject. An
unknown op or reply (or a missing reply) is a parse error.
[api]
opencode = { op = "permission-reply", reply = "once" }
The broker reads the pending request id from @agent_permission_request and the
server base URL from @agent_api_endpoint (both stamped by the OpenCode plugin),
falling back to [api.opencode] api_base in config.toml for the endpoint. An
empty request id or no resolvable endpoint refuses requires-unmet before the
lock. The POST is bounded by timeout_ms (connect and total, no retry): a 2xx is
the replied outcome, a 404 (the prompt was answered or withdrawn first) is
vanished with reason request-gone (exit 3), and an unreachable or
otherwise-failing server is error (exit 1). A 2xx also clears
@agent_permission_request: the id is spent, and leaving it stamped until the
plugin’s next permission.replied event lets a later reader mistake it for a
pending request. A 404 leaves the option alone, since it may already name a newer
request the plugin stamped. The API path never degrades to keystrokes — firing a
stale key sequence into a pane whose prompt state just proved unknowable is
exactly what the guard exists to prevent.
[text]: per-agent text transports
A text action delivers one string the caller supplies at the call, which is what
separates it from keys. The manifest still owns everything around that string:
which agents can receive it, when, and the keys that wrap it.
| field | required | type | meaning |
|---|---|---|---|
prefix | no | array of key | Keys sent before the string (empty by default: most composers already have focus). |
suffix | no | array of key | Keys sent after it. ["Enter"] submits the line. |
steer_now | no | bool | The agent queues a message typed while it is working rather than losing it. Default false. |
sigils = ["/", "!"]
[text]
claude = { suffix = ["Enter"] }
codex = { suffix = ["Enter"], steer_now = true }
The three deliveries (prefix, then the string, then suffix) happen inside one hold
of the pane’s single-flight lock, so nothing else tma drives can land between the
text and the Enter that submits it. The string itself goes through send-keys -l -- <string>: -l turns off named-key interpretation, so a message containing the
word Enter types five characters rather than pressing Return, and --
terminates the flags, so a message beginning with - is data.
steer_now = false is not just documentation. A text action refuses at a
working pane for any agent that has not declared it, whatever when says,
because tma cannot tell a queued message from a swallowed one: Gemini queues the
text and then hands it back to the composer, unsent, the moment the turn is
interrupted. Declare it per agent, from what that agent was watched doing.
The payload rules
The host checks the caller’s string before it runs a single tmux command, so a
refusal delivers nothing and does not even read the pane. Each has a reason
token, reported like a gate refusal (exit 4):
| token | rule |
|---|---|
empty | The string is empty or all whitespace. |
too-long | Over 4096 bytes. One steer is one message, not a file. |
control-bytes | Any C0 control, DEL, or C1 control. A steer is one line, so a newline or tab is refused too: a \r would submit early and \x1b is an escape. |
sigil | The first non-whitespace character is one of sigils. |
The sigils are the point of the set. /compact, /clear, /model and /exit
are the agent’s own command plane, tma’s own compact action is literally
claude = ["/compact", "Enter"], and a caller holding nothing but the ability to
send a message must not reach any of it. Widen or narrow the list per action by
shadowing the manifest.
[hook]: per-agent hook-lane transports
The third transport, beside a keystroke and an HTTP POST: an answer returned to the
agent’s own permission hook. [hook] maps an agent name to the verdict the broker
writes when a hook is parked on the pane’s current request. v1 covers claude, whose
PermissionRequest hook holds the tool call open while the hook reply
lane is
switched on.
[hook]
claude = { verdict = "allow" }
verdict is the only key and its vocabulary is closed: allow and deny. Any
other value, or a missing one, is a parse error. There is deliberately no spelling
for approve_always here, since a standing grant is not a decision to take from a
transport whose caller saw exactly one call.
Applicability is the union of all three tables. An agent may sit in [keys] and
[hook] at once, and the bundled approve and deny both do for claude: that
overlap IS the degradation path, because a fire falls through to the key sequence
whenever no hook is holding. An agent in [api] and [hook] is refused at parse
for the reason [keys] and [api] cannot share one either, and the direction
matters: a hook-lane miss falls through to keystrokes, never to HTTP, and a manifest
implying otherwise should not load. Only kind = "keys" may carry the table; a
kind = "exec" action with a [hook] is a structural error.
The broker takes this arm only when both halves line up: the action has a [hook]
entry for the pane’s agent, and a request record is parked for the id the pane
carries in @agent_permission_request. Under the pane’s held single-flight lock it
creates the verdict file (a temp file in the same directory, fsynced, then
link(2), so a second dispatch cannot answer one request twice), clears
@agent_permission_request, and reports outcome replied (exit 0). Spending the id
there is what makes a second dispatch quoting it refuse request-gone (exit 4) at
the --expect-permission-request
binder, before it reaches the file
at all. A verdict file that somehow already exists is the request having been
answered in the gap: vanished with reason request-gone (exit 3), the same pair
the API lane reports on a 404, and nothing is overwritten. With no record on disk
the arm is skipped and the [keys] sequence fires as it always has.
requires and the context env
An exec action’s command receives context only as environment variables (never
interpolated into the command string). requires names the keys that must be
non-empty for the gate to pass, so a script never half-runs on a missing value.
| token | env var | source |
|---|---|---|
session | TMA_SESSION_ID | the agent’s own session id (@agent_session) |
cwd | TMA_CWD | the pane’s current path |
pid | TMA_PID | the process-group leader pid |
title | TMA_TITLE | the pane title (untrusted text) |
Beyond the requires set, every exec action also receives TMA_PANE,
TMA_AGENT, TMA_STATE, TMA_DETAIL, TMA_LOCATOR, and TMA_ACTION. Quote
every TMA_* expansion in the script: a pane title is attacker-influenced text,
kept inert only by env transport.
Caller-supplied values arrive the same way. tma act <name> --arg <value> (
repeatable) sets:
| env var | value |
|---|---|
TMA_ARG | the first --arg value |
TMA_ARG_1 … TMA_ARG_N | every value in order |
TMA_ARG_COUNT | how many were passed |
None of the three is set when no --arg was passed, so a script can tell “not
passed” from “passed empty”. Values are never interpolated into command: they
cross as environment for the same reason TMA_TITLE does, so a value carrying
$(...) or ; is data the shell has no occasion to re-parse.
Every kind takes exactly one caller payload flag, or none, and a mismatch is a
usage error (exit 2) rather than a silently dropped value: exec takes --arg,
text requires --text, and keys takes neither. A keys action’s sequence is
manifest-static, which is what makes it reviewable; free text belongs in a text
action, where the manifest still owns the wrapping and the payload rules apply.
confirm: the second factor
confirm = true marks an action as wanting confirmation before it fires.
Enforcement is per-surface: the CLI takes --yes or an interactive prompt on a
TTY, the menu nests a confirm entry, and the broker refuses a confirm action from
a non-TTY without --yes so a script cannot stumble into one. Set it for anything
that injects into a live session or mutates a repo; tma cannot inspect what a user
script does, so this one bit is the author’s honest declaration.
Bundled actions
| name | kind | gate | effect |
|---|---|---|---|
approve | keys | state = ["blocked"], detail = ["permission"] | Affirmative answer to a permission prompt (1 for Claude and Gemini, y for Codex and Cursor; an API permission-reply once for OpenCode; a hook verdict = "allow" for Claude when one is holding). |
deny | keys | state = ["blocked"], detail = ["permission"] | Negative answer to a permission prompt (Escape for Claude/Codex, 3 for Gemini, n for Cursor; an API permission-reply reject for OpenCode; a hook verdict = "deny" for Claude when one is holding). |
interrupt | keys | state = ["working"] | Interrupt a working agent (Escape everywhere but Cursor, which takes C-c). |
compact | keys | state = ["idle"], context_pct_min = 75 | Compact the context window once it is high (/compact Enter for Claude). |
steer | text | state = ["idle"] | Send one line of your own text to an idle agent, submitted with Enter (Claude, Codex, OpenCode). |
steer_now | text | state = ["working"] | The same delivery at a working pane, for the agents that declared they queue a mid-turn message (Claude, Codex). |
Shadow any of these by dropping a file of the same stem in
~/.config/tma/actions/ (for example, retune compact’s threshold).
A full manifest
min_engine_version = "0.1"
name = "summarize"
label = "Summarize progress"
kind = "exec"
agents = ["claude"]
when = { state = ["working", "idle"] }
requires = ["session"]
confirm = true
detach = true
detach_timeout_ms = 120000
command = "~/.config/tma/actions/summarize.sh"
The remote wire protocol
The frames a remote device and a serving tma exchange. tma serve --stdio is the host end;
Serve tma over ssh is how a device gets one started, and
tma serve is the command’s own reference.
This page is the contract. crates/tma-proto ships the types, their versioning discipline and a
golden corpus, linked by the host and by the app alike, so there is one definition of the wire
rather than one on each side of the pipe. Everything here is pinned by tests: the corpus lives in
crates/tma-proto/vectors/, one JSON file per frame, and crates/tma-proto/README.md is the guide
to changing it.
Framing
One NDJSON line per message, in both directions.
{"schema":1,"id":"7","t":"window","pane":"%1","last":200,"budget":{"header_bytes":256,"read_bytes":1048576,"frame_bytes":32768}}
schema is the wire version, stated once per line, on the envelope. id is the caller’s own
correlation id, echoed on every response to that request; a streamed frame carries the id of the
subscription that opened it. t names the frame, and the rest of the line is that frame’s body,
inline.
Requests
t | carries | answered with |
|---|---|---|
hello | the client name, its version, and the device fingerprint | hello, or error when the envelope’s schema is one this host does not implement |
snapshot | an optional selector (session, repo, branch, agent, state) | snapshot |
subscribe | events plus the same selector | ack, then a stream of snapshot or edge frames |
card | a pane id | card |
window | a pane id, how many events, a cursor to page back from, and a budget | window |
event | a pane id and one cursor | event, with the body populated |
dispatch | a slot, a pane, an action, the binder, and the text or answers payload | receipt |
receipts | a slot id, a claim time, or both | receipts |
Responses
t | carries |
|---|---|
hello | the host name, its version, the reconcile interval, and the scopes this device was granted |
snapshot | agents, the fleet rows in scope |
edge | one pane’s state transition, as the cycle observed it |
card | what a blocked pane is asking: permission, question, informational, or none |
window | a page of transcript event headers, newest first, with an older cursor |
event | one event with its body |
receipt | what one dispatch resolved to |
receipts | the ledger, filtered |
ack | the request was accepted and has no body of its own |
error | a typed refusal: unsupported-schema, bad-request, not-found, cursor-invalid, scope-denied, too-many-connections, unsupported, internal |
A session, frame by frame
tma serve --stdio --device <id> is one process per connection: NDJSON requests
on stdin, NDJSON responses and events on stdout, logs on stderr. Nothing but
frames reaches stdout. Serve tma over ssh is how
one gets started; this is what it says once it is.
The handshake. The first frame of a session is hello, and its refusal is
typed rather than silent:
→ {"schema":1,"id":"1","t":"hello","app":"tma-ios","app_version":"1.0","device":"SHA256:0Mn3…"}
← {"schema":1,"id":"1","t":"hello","host":"studio","tma_version":"0.5.13","reconcile_interval_ms":2000,"scopes":["read","act:answer","act:steer"]}
The scopes come from the host’s device store, never from the request. The
device field the client sends is its own claim, used for the host’s log line
and for nothing else: the connection’s identity is the --device argument the
spawner passed after authenticating the caller, and serve trusts that and nothing
in the stream.
A frame whose envelope names a schema this build does not implement earns
unsupported-schema and the connection survives it, so a device can downgrade
instead of guessing why the pipe went quiet.
Converge, then subscribe. A client reads the fleet once and then asks to keep receiving it:
→ {"schema":1,"id":"2","t":"snapshot"}
← {"schema":1,"id":"2","t":"snapshot","agents":[{"pane":"%5","agent":"claude","state":"blocked",…}]}
→ {"schema":1,"id":"3","t":"subscribe","events":true}
← {"schema":1,"id":"3","t":"ack"}
← {"schema":1,"id":"3","t":"edge","at_ms":1730000001234,"pane":"%5","from":"blocked","to":"working",…}
Streamed frames carry the id of the subscribe that opened them, so a client can tell an event from an answer without tracking state.
That order is the resume discipline, and it is the whole of it. There is no
since cursor on the stream and no replay buffer, because the stream’s first
cycle is its baseline and emits no edges: it establishes what is there rather
than describing how it got there. So a connection that drops mid-stream is
recovered by dialling again, taking one snapshot, and subscribing, and
whatever the previous stream already delivered is swallowed by the new baseline
rather than re-sent. Synthesizing “appeared” edges for panes that were already
running would be a lie about when they started, which is the same reason
tma subscribe --events behaves this way locally.
"events": false streams whole snapshot frames instead of edges, suppressed
when a cycle repeats the last one. Either way the cadence is the host’s
reconcile_interval_ms.
Dispatching, and the three gates it passes.
→ {"schema":1,"id":"4","t":"dispatch","slot":"%5:1730000000000:approve","host":"studio","pane":"%5",
"action":"approve","binder":{"expect_episode_ms":1730000000000}}
← {"schema":1,"id":"4","t":"receipt","slot":"%5:…:approve","pane":"%5","action":"approve",
"outcome":"sent","exit_code":0,"cached":false,"device":"SHA256:0Mn3…","at_ms":1730000002000}
In order, and none of the three substitutes for another:
- The scope says this device may approve. It is checked before the slot is
claimed and before any tmux call, so a device holding only
readgets a receipt readingrefused/scope-deniedwith nothing spent and nothing sent. Anexecaction and an action outside the scope table are refused here too. - The slot says this approval has not already been sent. It is a caller-supplied idempotency key, claimed before the fire, in a per-host ledger shared by every serve process and every device. A repeat returns the cached receipt and dispatches nothing, which is what makes a re-tap after a pocket disconnect safe.
- The binder says the thing you approved is still on screen.
expect_episode_msandexpect_permission_requestare re-checked inside the pane’s single-flight lock, against the same read the gate is re-asserted from. A pane that moved on refusesepisode-changed; one that no longer carries the quoted id refusesrequest-gone. A zeroexpect_episode_msis “no expectation”, which is what a client sends when it has nothing to bind to.
force is not on this surface at any value: a device is never in the room to
have decided to skip the when gate.
Learning an outcome without dispatching for it. This is the transport’s normal case rather than its edge case, because a phone is suspended mid-request as a matter of routine:
→ {"schema":1,"id":"5","t":"receipts","slot":"%5:1730000000000:approve"}
← {"schema":1,"id":"5","t":"receipts","receipts":[{"slot":"%5:…","outcome":"sent","cached":true,…}]}
The ledger is a file, not connection state, so the receipt a lost response was carrying is there on the next connection, through a different serve process, and answerable to a different device than the one that dispatched.
Revocation. tma device revoke removes the record, and every live connection
re-reads the store per request and per publish. The next request is refused
scope-denied, the stream stops, and the process exits: absence of the record
is not absence of a scope, and a revoked device is not a read-only one.
Reading one pane. card, window and event all name a pane, and a pane
this host does not have is not-found on each of them:
→ {"schema":1,"id":"6","t":"card","pane":"%5"}
← {"schema":1,"id":"6","t":"card","card":"permission","pane":"%5","agent":"claude","lane":"hook",…}
→ {"schema":1,"id":"7","t":"window","pane":"%5","last":50}
← {"schema":1,"id":"7","t":"window","pane":"%5","agent":"claude","older":"t1.…","events":[…]}
One transcript reader is held for the life of the connection, so its per-file stat memo and its OpenCode database handle survive between requests. That is not a cache for speed: a reader that reconnected per page would make the writing agent’s own commits fail, which is a far worse bug than a slow page.
What a card carries
A card answers “what is this pane asking”, typed by variant so the wrong affordance cannot be
expressed. An informational card has no field an approve control could be put in, which is the
plan-dialog bug class made unrepresentable rather than merely unhandled.
Blocked-ness comes from detection and from nowhere else. A dangling tool call in a transcript
means a call is in flight, which a slow tool, an open prompt and a crashed process all produce. The
host reads it only to say what a blocked pane is blocked on, never that it is blocked, so a pane
the cycle calls working has no card whatever its transcript holds.
card | when | carries |
|---|---|---|
permission | blocked/permission | the lane, the options, an extraction confidence, the pending call, and the binder |
question | blocked/question, with the agent’s own question set fetched | the request id and the questions verbatim |
informational | every other blocked detail, a token this build has never heard of included | the detail and a short headline, and nothing to fire |
none | anything the cycle does not call blocked | nothing |
Lanes
lane says which transport produced the card and would answer it, so a structured card is visibly
distinguishable from a scraped one and a receipt’s reader can tell what “approved” meant.
hook: a blocking agent hook parked the request as data. The tool name and the tool input arrive as the agent’s own object, never as a rendered line, which is the whole reason the lane exists: a consent label wraps at a phone width and the wrap is not invertible. Exactly two options,allow-onceandreject-once; no always-grant is offered, because the second deliberate interaction has no surface on this lane yet.api: the reply travels over the agent’s own HTTP surface (OpenCode). A fact about the transport, not a claim that a dialog was read.screen: everything else. The options are the two actions this host would fire.
What extraction: failed means for the app
exact is the only value that licenses drawing the dialog’s own controls. wrapped and failed
both mean open the pane on the host, and not merely because a label might be truncated: a
wrapped consent line carries no signal telling a break inside a token from a break on a space, so
rejoining guesses, and a wrong guess yields a different filesystem path inside a consent string.
In this release failed is what every non-hook permission card reports, because no dialog extractor
ships for any agent yet. A hook card reports exact by construction: nothing was extracted. An
option carrying no option_id prints no index, so an app must not render one as a keycap: a
position the host invented is not a key the user can type.
What the host gathers for one
The pane’s own read comes first, and it is the same read a dispatch gates on, so a card and the
dispatch it invites describe one pane rather than two readings of it. It supplies the state, the
detail, the episode the binder quotes, the permission-request id, and the pending tool and call the
agent’s hook stamped. Everything after it is best-effort, and none of it can refuse the card:
- The parked hook record, when
@agent_permission_requestnames one on disk. Its presence is the whole difference between thehooklane and thescreenone: a hold that already expired left none behind, and the same pane degrades toscreen/failed. - The
approveanddenylabels for that agent, from the loaded action manifests. An agent no bundled action covers is offered nothing at all, rather than a control this host could not fire. - The pending question, for a pane blocked on one: a
GET /questionagainst the endpoint@agent_api_endpointresolved, bounded at 750 ms, and asked only when the pane carries a@agent_question_requestid. The request loop answers one frame at a time, so the fetch is short by construction; a server that does not answer in time yields an informational card, which is something to read, rather than a stalled connection. - The transcript tail: roughly twenty headers off the end of the pane’s own file, read only to
fill
pending_callwhen the pane’s stamps did not. A pane whose transcript this host cannot resolve still gets its card.
Only the pane read itself can fail the request, and a pane that is not there is not-found. A
device that gets no frame cannot even fall back to opening the pane on the host, which is why every
other gap subtracts detail instead.
Windows and events
A window is a page of transcript event headers, newest first, with an older cursor to page
back from. Headers only, by construction: every string leaf is capped at the header budget and no
body rides a window, so 200 events cost kilobytes. An event request fetches exactly one cursor’s
body.
The device asks and the host clamps. Every field of budget is capped at the host’s own
default (header_bytes 256, read_bytes 1 MiB, frame_bytes 32 KiB), and so is last: absent it
is 200, and it is clamped to 1000 however large a number arrives. A frame budget is a promise to the
network, so a caller cannot raise it. budget_truncated means the byte budget, not the event count,
ended the scan: the page is still exact and older pages on.
Which file is served comes from the pane and never from the request. The host resolves it from
@agent_transcript (the path the agent’s own hook payload named, so it is one stat), falling back
to walking the store’s layout from @agent_session and the pane’s working directory. A device names
a pane; it cannot name a path.
Cursors are opaque. A device only ever echoes back a token the host minted. A cursor stops
addressing its bytes when the file is rewritten, truncated or replaced, and a hand-edited one was
never valid; both earn cursor-invalid, whose one correct response is to drop the cursor and ask
for a fresh end-anchored window. unknown counts records in the page the reader could not
classify: a store that grows a record type raises it rather than erroring, and somebody still
notices.
Two stores are refused rather than served, each with unsupported and a sentence saying why:
cursor-agent’s transcript has no tool results, timestamps or version stamp, so a window over it
would render as holes, and OpenCode keeps its conversation in SQLite. An empty window would read as
“nothing happened”, which is the failure the refusal exists to avoid.
What a fleet row carries
Exactly the key set tma ls --json emits minus title, which is why the two are described in
one place: see Pane options and JSON contracts. A pane title
is agent-supplied text and rides no frame leaving the machine; tma_proto::FleetRow has no such
field, and a test compares its key set against the host’s writer so the two cannot drift.
Versioning
schema is 1 and grows additively: a new key keeps it, a removal or a re-typing bumps it. The
closed vocabularies (state, detail, outcome, reason, the option kinds, the error codes) grow
the same way, and every one but state has an Other arm, so a token minted after your build
round-trips intact instead of being collapsed into a neighbour. state is closed because the
published state vocabulary is frozen.
Unknown fields are ignored on parse and omitted on re-emission. Unknown variants are preserved. The two answers differ on purpose: dropping a field loses detail, dropping a variant changes meaning.
Architecture
This page explains why tma is shaped the way it is: seven small crates in one
workspace, three dependency rules the compiler enforces, and a single binary
that ships them all. The precise contracts live in the
reference section; the full decision record is kept in the
repository’s docs/internal/ notes rather than on this site.
One binary, seven crates
tma is one executable. Splitting it into crates buys nothing at the command
line, so why bother? Because three boundaries inside the code carry real
invariants, and before the split only convention kept them honest. Making each
boundary a crate edge hands the policing to cargo: a violation stops
compiling. The rest of this page is those three rules and the crates that
express them.
tma-core ← tma-tmux ← tma-runtime ← tma-daemon
↑ ↑
tma-ui-core ← tma-ui tma ─┘
↑ ↑ │
└────┴─────┘
Arrows point from a crate to what it depends on. The graph is acyclic, which
cargo guarantees, so the layers can only ever stack one way.
| crate | owns |
|---|---|
tma-core | The pure detection library: snapshot and evidence types, the manifest schema, identity resolution, and the verdict fold. No tmux, no I/O, no clock. The bundled agent manifests live here as compiled-in TOML, with their fixture tests beside them. |
tma-tmux | The only crate that spawns tmux. The read path (list-panes / capture-pane, the ps process walk), the control-mode client pool, and the guarded write adapter that stamps pane options. |
tma-runtime | Tier 2: config, manifest loading, the poll cycle, on-demand capture, the tma event hook bridge, the wire protocol, the single-fire notification primitive, and the pass-through ui module that is the only tmux surface display code may call (pane capture, focus and the active-client reads, the jump trail, attention clearing, display-menu, the watcher’s pid advertisement). |
tma-daemon | Tier 3, and only tier 3: the serve loop and notification dispatch. Nothing below it depends on it. |
tma-ui-core | The pure interaction core for the two live surfaces: each is an Elm-style fold from events (keys, ticks, refreshed rows) to requested effects, so selection, refresh gating, and preview caching are unit-tested without a terminal. |
tma-ui | The display layer: the shell loop driving both folds (input mapping, drawing, executing their effects), plus cross-session jump and the ls / status surfaces. It reads snapshots and never touches tmux directly. |
tma | The binary: clap dispatch, hook installation, and the --json value formatting the surfaces do not. |
An eighth crate, tma-test-support, holds the shared integration-test harness
(a scratch tmux socket, the daemon lock gate). It is a dev-dependency and never
ships.
Rule 1: the core is pure
tma-core takes a snapshot and a set of evidence records in, and returns a
verdict out. It reads no clock, opens no socket, and spawns no process. Every
timestamp it reasons about is injected by a caller, never read from the wall
clock inside the fold.
The payoff is testability. The whole detection decision, the part most likely
to be subtly wrong, is a pure function over data, so its fixture tests need no
tmux server and no agent running. Every bundled screen rule ships with a
redacted capture that proves it fires. When detection is a bug, the failing test
is a .txt fixture and a function call, not a flaky end-to-end run.
Rule 2: one tmux choke point
Every byte that goes to or comes from tmux passes through tma-tmux. It is
the one crate that shells out to the tmux command, holds the control-mode
clients, and performs the guarded option writes. Nothing above it constructs a
tmux command line.
This matters most for the write path. Concurrent producers (a status-line
poll, a hook firing, the daemon) all stamp the same pane options, and tmux has
no transactions. The safety comes from server-side conditional writes, and
concentrating them in one adapter means there is exactly one place that shape
can be right or wrong. It also makes everything above the choke point mockable
without a live server: tma-runtime drives detection against a Tmux handle,
and a test can hand it a scratch one.
The choke point also bounds failure. Every one-shot tmux command runs under a short timeout (about three seconds), so an unresponsive server degrades to a stale status segment and a skipped cycle rather than a hung process, and the focus-change hooks can never wedge the invoking client.
Rule 3: the tier boundary is strictly additive
tma runs at one of three tiers (the detection model
covers what each adds), and the top tier, the background daemon, is strictly
additive: it may lower latency but is never required. Before the split the code
contradicted that promise, because the hook-installer imported the event code
which imported the daemon code. The tier story said “tier 3 is optional” while
the dependency graph said “everything needs it”.
Now tma-daemon is a leaf that only the binary’s tma daemon subcommand
reaches. Every other code path in the binary depends on tma-runtime and
tma-tmux only. The wire protocol and the single-fire notification primitive
live in tma-runtime, not the daemon, precisely so a daemonless tma event
can reach them. The daemon imports those for its server side. The result is that
“tier 3 is never required” is now a fact cargo enforces: a stray tier-3 import
into a non-daemon module would not compile, and a source-guard test catches the
one legitimate edge drifting.
The same promise shapes the delivery acknowledgement. A hook hands its event to the daemon and only skips its own stamp when the daemon acknowledges it, so the acknowledgement has to mean “I produced a write plan”, not “I recognized the agent name”. A daemon is a long-lived process carrying the manifests it was compiled with, so after an upgrade the resident daemon can be older than the CLI firing at it. When its manifests map an event to nothing it refuses delivery and the hook stamps the pane itself. A refusal that is a decision, the subagent ownership guard declining a foreign session’s claim, is acknowledged instead: re-applying that on the client would write exactly the state the daemon just protected the pane from. The distinction is carried in the plan the mapping produces, not inferred from whether anything was written.
Why the UI reads snapshots only
Display code (tma-ui) reads a cycle report plus config, and never calls
tma-tmux. It has no dependency edge to it at all. Every tmux touchpoint the
UI genuinely needs, capturing a preview, moving focus, clearing attention, the
jump trail, the watch-pid advertisement, goes through a named helper in
tma-runtime::ui. Two layers enforce this at two strengths. The pure fold crate
(tma-ui-core) has no runtime edge at all, so Tmux is not even nameable there
and the compiler forbids it tmux entirely. The shell crate (tma-ui) does carry
a runtime-only edge, so Tmux reaches it through runtime’s re-export and the
compiler alone cannot stop a stray tmux.set_option(...); the tma-runtime::ui
helper surface plus a source-guard test (crates/tma-ui/tests/ui_boundary.rs,
which fails on any direct tmux.<method>( call) hold that boundary instead. The
picker cannot accidentally grow its own detection logic or its own write path;
it can only render what the runtime already decided. This keeps the surfaces
dumb, which is the same property that lets a user’s raw tmux show-options read
the exact same state the picker shows.
Agents in popups are invisible, by construction
One consequence of reading everything from tmux’s own enumeration is worth
stating outright. Run an agent inside a display-popup and tma will never see
it. A popup’s process lives in a hidden internal pane: $TMUX_PANE is empty
inside it, and list-panes -a, the one enumeration every tma surface starts
from, does not return it. So there is no pane id to stamp, no row to list, and
nothing to jump to. This is not a limitation tma can lift; the pane is not in the
model tmux exposes.
Reading from a popup is fine, which is why the picker binding is one: tma in a
popup lists panes, previews them, and jumps by switching the client, which it
asks tmux for directly (never from $TMUX_PANE, for exactly this reason). It is
the agent that must live in a real pane.
That is also why tma watch is bound to a split rather than a popup: popups are
modal and vanish on the next overlay, and a persistent dashboard needs a pane.
Distribution stays one binary
The crates are an internal seam, not a distribution story. The workspace builds
a single tma executable, installed once and invoked as one command. Prefixed
crate names (tma-core, tma-tmux, and so on) keep the door open to publishing
them on crates.io later without a rename, but that door is merely unlocked, not
walked through. A separate daemon binary was considered and rejected: tma daemon is a subcommand of the same executable, so there is only ever one thing
to install and keep on PATH.
The full numbered decision records, each with the options weighed and the
condition that would reopen it, live in the repository:
docs/internal/ARCHITECTURE.md
and
docs/internal/DAEMON.md.
The detection model
This page explains how tma decides what an agent pane is doing, and why it
trusts what it trusts. The exact option names and JSON keys are in
pane options and JSON contracts; the
per-agent evidence tables are in agent coverage.
The full arbitration record is kept in the repository’s docs/internal/ notes
rather than on this site.
Four states, plus detail, plus attention
The published state (@agent_state) is one of exactly four tokens: working,
blocked, idle, unknown. That vocabulary is closed and frozen. The reason
it stays small is that the only question every consumer actually asks is whose
move is it: the agent’s (working), the human’s (blocked), nobody’s
(idle), or unreadable (unknown). tma jump --blocked has to mean the same
thing for every agent, so the mappings from an agent’s own events into these
four tokens are normative, not something a manifest gets to redefine.
Prior tools reached for larger enums (six or seven states) and ended up
conflating orthogonal things. “Rate limited” and “error” are reasons, not
states; “done” is really “idle, and you haven’t looked yet”. tma splits those
onto two other axes so the state token stays stable:
-
@agent_detailis an open, additive token that qualifies the state (permission,rate_limit,compacting, and so on). It can be empty. A rate-limited agent isworking/rate_limit, because the agent auto-resumes and the ball is not with the human; an agent that halts asking for confirmation isblocked/permissionon its own prompt evidence.That
rate_limitsplit is implemented, not hypothetical. Claude Code waits out a usage limit in the open session and continues on its own, which isworking/rate_limit, and when the wait ends without continuing (the reset landed while the machine slept, or automatic continue was off) it sits there until you act, which isblocked/rate_limit. Both the hook claims and the screen rules carry it, per agent coverage. The detail is what makes that usable from a script:tma wait --until blockedfires for either kind of stop, and the token beside it says whether the pane needs a decision from you (permission,plan,trust) or merely needs the clock to come round (rate_limit). Poll it out oftma ls --json’sdetailkey and let the second kind wait. -
@agent_attentionis a presentation flag meaning “this changed and you have not seen it yet”. It is set on a noteworthy transition and cleared two ways. By navigation: on the pane you move to, and on the pane you move away from. And by input: the next thing your terminal sends at a pane a client of yours is displaying — usually a keystroke — if it lands after the mark went up. In one line, the done mark survives until your next input while that pane is on screen, or until you navigate off it. Nothing else takes it down. Walking away clears nothing, so leaving an agent running and going for coffee still leaves the mark waiting for you however long that takes; navigation that moves nothing clears nothing either, since selecting the pane or the window you are already in is not a departure. Navigation means a pane or a window: switching to another session does not clear, on purpose. A session is a workspace you come back to, and “which session did something finish in” is the questiontma statusandprefix-jexist to answer; leaving-means-seen is calibrated to the pane you were staring at, not to a workspace you walked out of. The mechanics agree: the notification tmux fires for a session change fires identically when the session did not change, and the one hook that can tell those apart also fires when you detach, when a popup opens, and not at all while any other client is attached to the session you left. A mark left standing on a session you walked out of is the safe half of that trade, and it comes down on your first keystroke back inside it. A finished agent isidlewith attention still set, which the surfaces render as the distinct done glyph. A mark that came down goes back up on the NEXT completion, and it has to be raised by the hook that reports the turn ending, not by the fold: the fold sees only states, and the second completion of a pane that never visibly worked in between is anidle→idleedge it cannot tell from a quiet idle pane. The manifest names that hook (turn_end), the intake stamps@agent_turn_atwhen it raises, and one turn end reported on two channels (codex sends bothStopandnotify) still marks one completion, because the second finds the mark already standing. Keeping done on this separate flag rather than making it a fifth state token is deliberate: the closedstatevocabulary stays four tokens, and a script readingstatenever has its value change shape under it. Attention is also not the notification record: navigating clears attention, but a blocked episode you glanced at and walked away from is still blocked, so the notifier keeps its own separate marker.The input half reads two facts tmux already keeps: which pane each client is displaying, and when that client last received real terminal input (
#{client_activity}, which moves for anything your terminal genuinely sends — a keystroke, the prefix key, the mouse, and the focus reports it sends whilefocus-eventsis on — and never for pane output or fortma’s own polling). The focus reports are worth knowing about: withfocus-events on, switching to another application counts as input, so a mark raised before you alt-tabbed away comes down. That is the same rule as the navigation half, where leaving a pane also counts as having seen it. It is an ordering against the raise, never a window: “you typed in the last N seconds” would eat the mark for the very case the mark exists to serve. Two limits are honest ones. A control-mode client (iTerm2’s-CC) has its activity clock frozen at attach, so under-CCthis half does nothing and navigation is the only clear. And a person who reads the output without touching the keyboard looks exactly like a person who is not there, so their mark stands until they type or move.
The same four distinctions, arrived at independently
The strongest evidence that these are the natural cuts is that an agent vendor
reached the same ones without reference to tma. OpenAI’s codex app-server
publishes a thread/status/changed notification whose ThreadStatus is
notLoaded, idle, systemError, or active carrying an activeFlags list of
waitingOnApproval and waitingOnUserInput
(codex-rs/app-server-protocol/schema/typescript/v2/ThreadStatus.ts). It maps
onto this vocabulary without loss: active with no flags is working, active
plus waitingOnApproval or waitingOnUserInput is blocked, idle is idle,
and notLoaded is a pane with no agent registered on it at all. systemError is
the case that argues for the axis split rather than against it: an error is a
reason, so it belongs on the detail axis, and it is the declared error detail
token rather than a fifth state.
The two flag names line up with tma’s detail tokens, though only one of them
exactly. waitingOnApproval is permission, the same distinction under another
name. waitingOnUserInput has no exact counterpart: the nearest token is
question, declared in tma-core and not yet emitted by any bundled manifest,
so the honest statement is that the two vocabularies agree on the distinction and
not on the word. Publishing the token set as something another tool can write to
follows from all of this, and is written down as the @agent_state
contract.
Three evidence sources, one ranking
tma learns a pane’s state from three kinds of evidence, in descending
fidelity:
- Agent hooks. A cooperating agent runs a command at each lifecycle point,
so it tells
tmait just blocked, at the instant it blocks, with zero inference. Highest fidelity. - Screen chrome. Capturing the pane and matching its on-screen text against the agent’s manifest rules. This is how a hookless agent, or a missed hook, still gets detected.
- Process facts and the pane title. The process walk (is the agent still alive?) and the OSC title the agent publishes. Output activity is not on this list: a pane producing bytes tells the daemon when to look, not what state to report.
They are combined by a deterministic fold, not a probabilistic fusion. The
sources have a natural strict ranking, and the verdict has to be explainable
(tma debug explain names the rule or event that decided), so weighting would
be both unnecessary and opaque. The order the fold applies is:
- a fresh hook event from a registered pane;
- visible blocker chrome on the live viewport;
- visible working chrome, which means
working; - visible idle chrome, which means
idle; - otherwise hold the previous state, or
unknown.
Two things stop the fold before it reads the screen at all. If the pane’s
foreground process is not the agent, the screen belongs to something else and
the verdict is capped at unknown. What that cap governs is the screen, not
what the agent said about itself: a pane already carrying a hook claim keeps it
as long as the agent’s own process is still in the pane’s tree. An agent that
hands the tty to $EDITOR or pipes a diff into a pager is alive and mid-task,
and dropping its blocked the moment vim comes up would lose exactly the
state you needed. A pane with no hook claim behind it has only the process walk
to go on, and that walk is stale while someone else holds the foreground, so it
still caps at unknown — as does a pane whose agent pid is gone, which is the
claim expiring on process evidence rather than on the foreground. If the
viewport is not the live screen, the
last state is frozen rather than matched against whatever is on display: a rule
written for the current prompt would happily match a prompt you scrolled back
to. That freeze keys on the scroll offset, not on copy-mode itself. tmux
reports offset 0 the moment you enter copy-mode, and at offset 0 you are still
looking at the live screen, so entering copy-mode to copy an error message does
not quietly suspend detection on the pane; scrolling up by a line does.
That is tmux’s scroll, and it is the only one tma can see. Agents draw on the alternate screen and scroll their transcript inside their own TUI, which moves no tmux fact, so scrolling back through a conversation in the agent does not freeze anything. It does not need to: those TUIs pin the chrome the rules match (spinner, composer, permission dialog) to the bottom of the screen, and it stays put while the transcript above it moves.
Why a hook can lose to the screen, and when it cannot
Ranking hooks first raises an obvious hazard: a stale hook claim outliving
reality. The fold handles this with coverage-aware decay rather than a blanket
timeout. A hook claim is expired by process evidence (the pid is gone, so the
agent died without firing its end hook) at any time. It is expired by screen
evidence only for states the agent’s manifest declares its screen rules can
actually see. A blocked agent can sit silent for ten minutes precisely because a
permission prompt produces no output, so the reconciliation sweep must never
read that silence as idle and flip a hook-reported blocked.
Silence, then, never expires anything. What can expire a claim is the screen
saying something else, and even that has to clear three gates at once: the
claim is older than its decay window, the manifest declares the claimed state
screen-visible, and this capture carries positive contrary chrome. blocked
gets its own, much longer window (blocked_decay_secs, five minutes against
hook_decay_secs’ sixty seconds) because answering a prompt takes as long
as it takes. It is a window rather than “never” for one failure mode: a
follow-up hook that never fired. Without a bound, one dropped event pins a pane
blocked for the rest of the session, and no amount of screen evidence, an idle
composer sitting there with the prompt long gone, could correct it. With the
bound, an agent whose manifest can actually read blocked off the screen (see
agent coverage) recovers on its own; one whose
manifest cannot, such as pi, keeps holding, because for that agent the absence
of blocker chrome carries no information.
The one case where blocker chrome overrides a live hook claim is decided by
evidence timestamps, not by “immediately” or “after a wait”. Visible blocker
chrome overrides a working or idle hook claim only when the stamped evidence
timestamp predates the capture. That single rule resolves the answered-prompt
race in both directions. Capture at T0 sees a prompt; the user answers; the hook
stamps working at T1. The capture’s blocked write carries time T0, which is
older than T1, so it is suppressed: the hook is newer evidence and wins. Reverse
the order and the capture is newer, so the block wins with no decay wait
(millisecond timestamps keep that ordering unambiguous; see the
pane options reference).
Identifying the pane
Before any of this runs, tma has to decide a pane is an agent pane at all. A
pane earns that identity two ways: by observation (the process walk finds a known
agent binary) or by self-registration (a hook stamped it). Observation is what
lets hookless agents show up without cooperation. Some agents run under a generic
process name (several launch as node), where the binary name alone would either
miss them or match every unrelated app; for those, a manifest adds
title_patterns that narrow a generic process match, so the pane is that agent
only when the process and the pane title agree. A hook registration is
authoritative and skips the title gate; title flicker is absorbed by holding the
last match while the pane’s agent pid is unchanged.
Narrowing shrinks the false-positive window but cannot close it: a dev server
whose title happens to match still looks like an agent. That pane, and only that
pane, opts out with tmux set-option -p @agent_ignore 1, after which it is
never identified, captured, or stamped, and any stamp it still carries is
cleared — no need to disable the whole agent type. tma doctor lists the panes
carrying it (see pane options).
Two kinds of pane are ruled out before the walk even runs, because for both the
walk would come back empty while the screen invites a false match. A remote shell
(ssh, mosh, docker, and friends) runs its real work on a host tma cannot
see. A nested multiplexer client (tmux, zellij, screen, dvtm, abduco)
is the same shape one level down: whatever runs inside belongs to the inner
server, not to this pane’s process tree, and the outer pane’s screen is a
composite of the inner ones that a screen rule would happily match by
coincidence. Neither gets a stamp or a row, and a stamp left on such a pane is
removed rather than trusted. tma debug explain names both (out_of_scope with
its kind); tma doctor lists the nested case, saying where the state actually
lives.
A live hook registration outranks both carve-outs. The carve-outs exist because
the walk comes back empty and the screen is somebody else’s; a registration is
positive evidence of the thing they infer the absence of — an agent fired a hook
in this pane, which it could only do from inside. So a registered pane keeps
its stamps and its row even when the foreground is docker or a nested tmux:
tma stops capturing it (nothing readable crosses the boundary) and lets the hook
path be its only evidence source, with the usual dead-registration reaper as the
liveness bound. That is what makes an agent in a
container work. Without a registration
nothing changes: an outer nested-tmux pane is as invisible as it always was.
Three tiers, none required
The same detection runs at three tiers. Each is a strict upgrade in latency or coverage, and consumers see no difference between them because they all read the same stamped options.
- Polling floor. Any one-shot invocation refreshes stale panes when it runs.
This is the only tier a hookless agent gets with no daemon, and it has no
driver of its own: something must invoke
tmafor stamps to stay fresh.#(tma status)instatus-rightis that required ambient driver; without it, ambient surfaces render nothing. - Hook tier.
tma eventdirect-stamps the moment a hook fires, with no daemon involved. State is event-latency, and a residenttma watchrefreshes within about a fifth of a second of a focus change: theafter-select-pane/session-window-changedhooks that already clear attention also walk panes for a watcher’s advertised pid (@tma_watch_pid, set on the watcher’s own pane so it dies with that pane) and sendSIGUSR1, which the watcher treats as “refresh now”. The picker popup is deliberately outside that scheme:display-popup -Eruns in a hidden panelist-panes -anever enumerates, so no hook can find it, and its own one-second refresh is what keeps it current. This is the sweet spot for a single-user setup: hook-fresh state, no background process. - Daemon tier. A background process holds control-mode clients, captures hookless panes on an activity-quiet edge, runs a slow reconciliation sweep, and dispatches deduplicated notifications. It adds cross-event intelligence, not basic liveness.
Deduplication is per state run, not per pane and not per episode. Whichever
process fires a notification stamps the time on the pane as
@agent_notified_at, and a notifier fires only when that marker predates the
pane’s @agent_since, which is written once per state. Five producers noticing
the same blocked run therefore ring once between them, while an agent that
blocks, gets answered, and later finishes rings twice (blocked, then done, if
you opted into done). The marker is a pane option rather than daemon memory on
purpose: a daemon restart mid-session must not re-announce every blocked pane
you already dealt with. Without a daemon nothing is resident to dispatch from,
so the hook path can fire for itself instead, opt-in via notify.from_event
(see notifications).
tma doctor reports which tier each pane is actually running at and why it is
not higher.
Reading a pane only when it can have changed
A capture is a capture-pane subprocess, and the poll cycle spawns them one
after another, so a session with a dozen agent panes pays for every one on every
cycle even when nothing has happened. The cost was measured against a release
build on a throwaway server of 40 panes, 10 of them agents (tmux 3.6, macOS,
arm64): a cold cycle that captures all ten takes about 104 ms, while the same
cycle with every stamp fresh, capturing nothing, takes about 24 ms. That is
roughly 8 ms of cycle time per agent pane, nearly all of it process spawn rather
than capture payload, and it grows linearly with the number of agents. The cycle
avoids most of that by asking tmux a cheaper
question first: #{window_activity}, the timestamp of the last output in the
pane’s window. When that timestamp falls strictly before the pane’s own
@agent_stamped_at, the screen behind the stored verdict is byte-for-byte the
screen a capture would return, so the cycle reuses the stamp and reads nothing.
The check is window-scoped, which is conservative in the useful direction: a
quiet window proves a quiet pane, never the reverse. tmux reports it in whole
seconds, so a write in the same second as the stamp counts as activity.
An unchanged screen is not the same as an unchanged verdict, because two of the
fold’s rules are driven by the clock rather than the screen. The dwell that
delays a working→idle publish resolves off idle chrome that is already on the
unchanged screen, so a working pane is always re-read. A hook claim past its
decay window can be expired by contrary chrome that has likewise been sitting
there since before the stamp, so a claim that old is re-read too. Inside its
window the claim holds whatever the screen says, and since a skip writes nothing,
the next cycle re-asks the same question against a later clock and captures the
moment either window closes. --debug-timing reports the skips as
capture-skipped next to the captures.
Why concurrent producers are safe
Several producers stamp the same pane options at once: a status poll in one client, another client’s poll, a hook firing, the daemon. tmux options have no transactions, no compare-and-set, and no writer identity, so an uncoordinated read-then-write loses races exactly on the transitions that matter, because hooks fire inside the read-to-write window.
The fix is to never decide client-side. Every guarded write is a server-side
conditional (set-option -pF), which tmux expands in the target pane’s context
atomically at write time. A capture producer’s state write carries a guard that
says, in effect, “only commit if a hook has not already claimed this pane with
newer evidence”. The whole chained write, state, provenance, timestamps, detail,
and the write-once transition marker, carries the same suppression condition,
so the tuple commits together or holds together. A losing producer changes
nothing, including the notification marker, so it cannot fire a stray alert
either. Everything that is not guarded this way is last-writer-wins over
deterministic values (the same fold, the same persisted inputs), which
converges.
Honest margins
Two properties are margins, not proofs, and the design says so plainly rather than dressing them up.
A margin is tolerable here only because the two directions of error cost
different amounts. A blocked agent shown as working or idle is the expensive
failure: you never go back, and the agent sits on its prompt until you happen to
look. A working agent shown as idle for a cycle costs you one glance. Where the
evidence is genuinely ambiguous the fold leans toward blocked. It stops short of
guessing, though, because a false blocked flag is expensive in its own currency:
flags that turn out to be nothing teach you to ignore the flag, and then the real
one goes unanswered too. So blocked is asserted only from direct evidence, a
blocked-class hook event or blocker chrome on the live viewport, and never
inferred from silence, from the pane title, or from a lull in output.
The daemon triggers a hookless capture on an activity-quiet edge, the moment a
pane stops producing output, because a permission prompt is exactly when output
stops. But the activity gauge sees %output events, not the kernel’s buffers,
so “quiet” is not proof that nothing is happening; it is a strong signal with a
settle window layered on top. The quiet threshold plus settle is a generous
empirical margin, chosen to be safely past real output bursts, not a structural
guarantee. Calling it a margin is the honest description. What the quiet edge
buys is a look rather than a verdict: it decides when to capture, and the
blocked call still has to come off chrome that is actually on the screen.
Pure event-driving fails open: a hook can be missed (the agent was killed with
-9, the hook was misconfigured, the daemon restarted mid-session). So state is
never only event-driven. The recovery paths are layered: process evidence
expires a claim whose pid is gone; a pane close clears state immediately; and a
low-frequency reconciliation sweep, the full poll cycle every 30 to 60 seconds,
rediscovers agents that never announced themselves and corrects any drift. The
governing invariant is that events drive state and the sweep repairs it, so
the sweep’s latency bounds only how long an anomaly can persist, never how fast
a normal transition is seen. Quitting an agent is a normal transition on that
reading: the daemon removes the pane’s stamp and recomputes both rollups on the
first quiet edge after the exit (the shell repainting its prompt is that edge),
and the sweep is the backstop for a pane no edge arrives on.
The numbered decision records behind this model live in the repository:
docs/internal/ARCHITECTURE.md
for the arbitration rules and
docs/internal/DAEMON.md
for the event sources and the daemon tier.
Agent transcript stores
Every coding agent tma watches writes its conversation to disk, and no two of
them agree on how. tma transcript reads
five of those stores into one event vocabulary. This page is the honest account
of what that buys and what it does not, because a reader that quietly renders
half a conversation is worse than one that says it cannot.
What each store is
| agent | store | tail | served |
|---|---|---|---|
| claude | ~/.claude/projects/<cwd-slug>/<session>.jsonl, plus <session>/subagents/agent-*.jsonl | byte offset | yes |
| codex | $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<iso>-<session>.jsonl | byte offset | yes |
| gemini | ~/.gemini/tmp/<projectHash>/chats/session-<iso>-<short>.jsonl | byte offset, with $set dropped | yes |
| pi | ~/.pi/agent/sessions/--<cwd-slug>--/<iso>_<session>.jsonl | byte offset | yes |
| OpenCode | ~/.local/share/opencode/opencode.db (SQLite) | event.seq | yes |
| cursor-agent | ~/.cursor/projects/<cwd-slug>/agent-transcripts/<chat>/<chat>.jsonl | byte offset | no |
Four of the served stores are append-only JSONL and the fifth is a database, and all five are written during the turn rather than at the end of it, so what you read is what the agent has done so far rather than what it did last time it finished.
The refusal
cursor-agent is refused because its transcript is not one. It records the
user’s prompt, the assistant’s prose, a bare tool_use, and turn_ended. There
is no tool result, no timestamp, no version stamp, and no header record; a
driven run that used a tool wrote the call and then the model’s reply, and the
tool’s output never landed in the file at all. Rendering that beside a claude
session would produce a screen full of holes that looks like tma is broken.
Returning an empty window would be worse still, because “nothing happened” is a
claim, and it would be false. So the request is refused with store-incomplete,
which names the store and the reason.
OpenCode, the store that is a database
Everything OpenCode writes lives in one SQLite file holding every session it has
ever run, so none of the file reader above applies to it: there is no path to
stat, no byte offset to page from, and no way to read it without a SQLite
client. tma links one (the transcript crate’s opencode feature, on in the
binary you install) rather than driving the sqlite3 command, which means an
OpenCode transcript needs nothing on your PATH and no second process.
The reader holds one connection, and that is the whole design. A
read-only reader that opens a fresh connection for each poll makes the writing
agent’s own commits fail: measured over 400 committed appends, 42 of them came
back database is locked against a reconnecting reader and none against a
reader holding one connection, which also read about 40 times faster. Opening is
the moment that costs, not reading. Attaching to a WAL database takes a lock the
writer wants, for long enough to lose about one commit in every twenty-five
opens, and opencode’s own connections carry no busy timeout to ride that out. A
reader that opens once pays that risk once; one that opens a thousand times pays
it a thousand times. So tma opens each database once and keeps it. It opens read-only twice over (the
SQLITE_OPEN_READ_ONLY flag and mode=ro in the URI), never writes, and never
checkpoints. It also never asks SQLite to treat the file as immutable, which
would be faster and would be a lie: there is a writer, and telling SQLite
otherwise is how a reader gets silently stale data instead of correct data.
History and the live tail come from different tables. OpenCode moved to an
event-sourced store partway through its life, so older sessions have only
message and part rows while newer ones also have an event log keyed
(aggregate_id, seq) with a high-water mark per aggregate. That log is the best
tail any of these agents offers, a subscriber stores one integer, but it does
not cover the sessions written before it existed. tma serves every window from
message and part ordered by time_created, and uses the event log only as a
notification that something changed, then re-reads the row it names. The payload
in the log is never the thing rendered, which is what keeps a schema change
there from becoming a wrong transcript here.
A tool call and its result are the same row. Where a JSONL store appends a
call record and later a result record, OpenCode mutates one part row in place:
state.status walks from pending to running to completed or error. So
the call event is minted the moment the row appears and the result event only
once it settles, and a row seen three times is one call whose status advanced,
not three events. Two details of that are worth knowing if you are reading the
output: a call waiting on your approval reads as running, never pending, and
a call you denied settles as error with your own feedback quoted in the body.
One more difference from the file stores: OpenCode’s cursors are addressed by
(message timestamp, index) rather than by byte offset, since a database has no
meaningful “size when the cursor was minted”. They are still opaque, still page
backwards without gaps, and are still refused as cursor-invalid when they
belong to a different database.
Two quirks that would silently corrupt a rendering
These are worth naming because both fail quietly: neither produces an error, and neither raises the unknown counter.
codex writes every turn twice. event_msg is the UI stream and
response_item is the model-API transcript, and both are in the same file. A
reader that maps both renders each message twice. The split tma takes is prose
from event_msg, tool calls from response_item, and each channel’s mirror of
the other as bookkeeping.
pi hoists its tool results to a role of their own. A toolResult record’s
content is a plain text block, so a reader that dispatches on block type
before role maps the tool’s output to assistant prose: the file’s contents,
rendered as if the model had said them, at 100% mapped and zero unknowns. That
is why every adapter reads the record’s role first. It is also why a store’s
role vocabulary is part of the pinned fixture corpus rather than a detail of the
code.
gemini restates its whole history. Interleaved with the per-message records
are $set records that replace the entire messages array; nineteen real
messages on the spike machine came with twenty-five restatements of them. Those
are dropped, not rendered.
What no store can tell you
Nothing streams tokens. Every store on disk writes settled records. The finest grain available is a whole message, so a reader can show “the agent is writing” (from tma’s own detection) and then the whole paragraph when it lands, but never a word at a time.
An unresolved tool call does not mean blocked. The transcript answers what
the agent wants to do, never why it stopped. A tool call with no result yet
means in flight, and a slow command, a permission prompt and a crashed process
are indistinguishable from the file alone. That distinction lives in tma’s
detection and its @agent_state, and joining the two is the caller’s job. A
design that infers “blocked” from a dangling call will fire on every slow test
run.
The join is worth making, though, because the transcript is the only place the
specifics live: for claude and codex, a pending call’s real tool name and real
arguments are on disk before any approval, which is the difference between
asking “allow this?” and asking “allow Bash: rm -rf build/?”.
Token usage is uneven. codex, gemini and pi write it per turn; pi is the only one that writes cost. Claude writes none of it in the transcript at all (its numbers arrive through the statusline shim, which is why tma reads them there instead).
Subagents are not merged. A claude Task call becomes a subagent_ref
pointing at a child file, and --subagent <id> serves that child as its own
session. There is deliberately no interleaving: the parent and child streams
have no ordering guarantee between them, and inventing one would put events in
an order neither agent wrote.
Drift, and why the reader never errors on it
Store formats move fast. Thirty-seven claude sessions on one machine spanned eight CLI versions in two months, and inside that window subagent transcripts moved out of the parent file entirely and two new record types appeared.
So the reader is built to degrade, not to fail. An unseen record type becomes an
unknown event and raises a counter; an unseen version is simply read. A
reader that errored on drift would break the day an agent shipped a release,
which is the one thing it must never do. The counter is what turns that
tolerance back into a signal: tma’s committed fixture corpus asserts the count
is zero for every version it pins, and a deliberately-drifted fixture asserts it
is not, so a refresh from a newer session surfaces the change as a test diff
instead of as a hole a user notices first.
The security model
tma has one security boundary, and it is your user account. Everything below follows from that: what the event channel does and does not check, why the act broker verifies state twice, and why an action’s context arrives as environment rather than as text spliced into a command.
tma event is a cooperative channel, not an authenticated one
Worth knowing before you build on it: tma event authenticates nothing. It takes
the pane from $TMUX_PANE, maps the event through the named agent’s manifest, and
stamps — so any process running as you, on a tmux server you can reach, can
stamp any pane’s state. There is no caller check, no token, and none is planned;
the only filter in the path is the subagent guard, which compares a payload’s
session_id against the pane’s stored @agent_session and exists to stop an
agent’s own subagents from clobbering the parent’s row, not to stop you.
That is a deliberate consequence of the design rather than a gap in it. tma’s
state lives in tmux pane options, which any same-user process can already write
with tmux set-option -p; an authenticated event path would guard the front door
of a house with no walls. What it buys is that anything able to run a command can
report state — a shell script, a CI step, an agent in a container — with no
daemon, no port, and no registration.
The daemon’s socket is gated the same way and no further: its directory is
created 0700 and the socket chmoded 0600 (both best-effort), so the local
user reaches it and nobody else does. It checks no peer credentials. It does
re-derive state from the raw (kind, payload) through the same mapping the
direct path uses rather than trusting a pre-computed state off the wire, which is
integrity of the mapping, not authorization of the sender.
Anyone who can run processes as you can also drive tma; nobody else can reach it at all.
Why the act broker verifies twice
The failure that matters when firing an action is a stale one. A surface painted
blocked at some instant, you pressed two seconds later, and the agent left
blocked one second in. A blind y Enter now answers a different prompt, and
possibly a destructive one.
So the gate is checked twice: once when the menu is built or the fire is
requested, and again under the held pane lock immediately before the keys go out.
For a keys action the first check does not trust a stale stamp either: if the
pane’s @agent_stamped_at is older than the freshness bound (three seconds by
default, the status-line cadence plus slack), the broker runs one on-demand
detection cycle on that pane and gates on the result.
A residual window remains, and it is accepted rather than closed. tmux has no
transactional send, so nothing local can make “read the state” and “send the
keys” one operation. What the second check buys is shrinking that window from the
seconds a surface repaint cycle allows down to the gap between one option read
and one send-keys. The stale-paint case, which is the common one, is eliminated
entirely; what is left is the same residue every interactive user lives with when
they type into a pane.
--force skips the when gate only. It never skips requires, and never skips
the single-flight lock: requires is a correctness precondition rather than a
staleness guard, and a forced action with an empty TMA_SESSION_ID is exactly
the half-run it exists to prevent.
Why --all is a fan-out and not an inbox
tma act --all fires one action on every pane a selector matched. It exists for
the two things you genuinely mean across a whole fleet at once: interrupt them,
or deny them. It is not a unified permission inbox, and it is not going to become
one.
The reason is not squeamishness about scale. Batch approval is a documented attack surface. WorkOS wrote it up on 2026-08-05: adversaries embed a dangerous operation inside a batch of benign ones and add language discouraging individual review, phrases like “don’t bother reviewing each one” (https://workos.com/blog/approval-fatigue-agent-governance). A safeguard that fires often enough to become a rhythm is a safeguard that trains you to defeat it. The local version of the same failure is smaller and better attested: an action delivers the key sequence its manifest declares, and one mis-typed dialog turns an approve into something else. tma has shipped that bug and fixed it. A fan-out multiplies whichever one you have by the number of panes that matched.
So --all stays, the guards stay per pane (its own lock, its own gate
re-verification, N independent fires), and every line it writes to the act audit
log carries all: true and a shared
batch id, which is what makes a bulk fire visible afterwards rather than
indistinguishable from a burst of typing. Approving a prompt is a decision you
make one prompt at a time; that is what the picker and the action menu are for.
tma is a human’s tool
Every guard in this document assumes a person is on the other end of the act. tma cannot check that assumption, and it does not try.
Claude Code’s auto-mode classifier blocks, by default, “Sending keystrokes to
Claude Code’s own tmux pane to drive its own interface”, which it treats as
Claude changing its own permissions or oversight
(permission modes). That is
a fair description of tma’s entire act path, and the vendor is right to name it.
A human firing tma act approve is the tool working. An agent shelling out to
tma act approve against its own pane, or a sibling’s, is oversight evasion
wearing the same command.
tma does not detect the difference, because it cannot: an act arrives as a
process running as you, and a shell you typed into and a shell an agent spawned
are the same kind of process. What tma does instead is make it checkable
afterwards. The act audit log’s source
separates cli (a person at a TTY) from cli-yes (--yes, or no TTY to prompt
on, which is where a script or an agent lands), and the repeat counter surfaces
the same prompt being answered over and over. If you run agents in a mode that
lets them run arbitrary commands, turn the log on and read it. It is the only
place that question gets answered.
Nothing but a person at the dialog is consent
A notification is not an approval. A tap on one is not an approval. A message
from another agent, a queued command, and the exit status of a [notify] command hook are not approvals either, and none of them is ever treated as one:
a hook that exits 0 has reported success at notifying you, nothing more.
Two things answer an agent’s prompt. A keystroke you send to the pane, and a
tma act you ran. That is the whole list, and it is not going to grow.
The vendors landed on the same line. Claude Code’s cross-session messaging says
plainly that a message from another session cannot approve a permission prompt
and “never counts as your consent”
(cross-session messaging),
and the same rule holds for its agent teams: a teammate’s prompt goes to the
lead’s session for a human to answer. tma’s version of the rule is the
confirm flag and the guards above. A surface may tell you an agent is waiting
and it may put the fire one keypress away, but the keypress is yours.
Why action context arrives as environment
An exec action’s command string is handed to sh -c verbatim. tma substitutes
nothing into it. Everything the action needs to know about its target arrives as
environment variables instead: TMA_PANE, TMA_AGENT, TMA_STATE,
TMA_DETAIL, TMA_SESSION_ID, TMA_CWD, TMA_PID, TMA_LOCATOR, TMA_TITLE,
TMA_ACTION, and the caller’s --arg values as TMA_ARG, TMA_ARG_1..N, and
TMA_ARG_COUNT.
Interpolating any of that into the command string (command = "summarize.sh {pane}") would be a quoting injection waiting to happen. A pane title is
attacker-influenced text: an agent prints whatever its tool output tells it to,
and tool output can come from a repository, a web page, or a model. Environment
variables cross the exec boundary without interpolation and without shell
re-parsing, so a hostile title is inert data on the way in.
Inert on the way in is not inert on the way through. The env transport protects exactly one boundary, tma’s. A script of yours that expands one of those variables unquoted re-parses it in your shell, which hands the hostile value back its teeth:
echo "$TMA_TITLE" # data
echo $TMA_TITLE # re-parsed by your shell
So quote every TMA_* expansion, --arg values included, and pass values to
other programs as arguments rather than building a command string out of them.
The same reasoning is why the action command runs in tma’s own working directory
rather than the pane’s: a script that wants the agent’s directory says so with
cd "$TMA_CWD".
See also
- Author a custom action for the authoring side of the two rules above.
tma actfor the gate, the lock, and the exit codes.- The act audit log for the record that
makes
sourcecheckable after the fact. - Architecture for the crate boundaries that keep the write path in one place.