Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 tma itself, 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 with tma doctor, and reading agent state from a status bar or script.
  • Reference documents the contracts precisely: the command-line interface, the keybindings, the config.toml keys, 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 buildswhat tma reads
pane process probe#{pane_pid} and a process-tree walk
bottom-of-buffer screen snapshotcapture-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 buspane and window user options
session persistence, detach and attachtmux 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-popup is invisible for the same reason: tmux does not enumerate it.

See also

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 tma does 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, and steer --text for 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] stall covers the other failure, a pane that has been working far longer than it should be.
  • To understand how tma decides 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:

variableeffect
TMA_VERSIONInstall this tag instead of the latest release.
TMA_INSTALL_DIRInstall here instead of ~/.local/bin. Created if it does not exist.
TMA_NO_COMPLETIONSSet 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:

outputwhat it is
packages.<system>.tma, packages.<system>.defaultthe tma package, versioned from the workspace Cargo.toml
overlays.defaultadds tma to a nixpkgs instance
homeModules.default, homeModules.tmathe Home Manager module below (homeManagerModules is an alias for older setups)
devShells.defaultthe contributor shell: the package’s build inputs plus clippy, rustfmt, rust-analyzer, tmux, mise, and mdbook
checks.<system>.tmathe 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:

optiontypedefaulteffect
programs.tma.enableboolfalseInstalls the package and writes the files the options below ask for.
programs.tma.packagepackagepkgs.tma when the overlay is applied, otherwise built from the module’s own source treeThe package to install.
programs.tma.settingsTOML 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.agentsattrset 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.enableboolfalseAppends 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

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 unindexed set-hook -g replaces the whole hook array, so it deletes tma’s entry (and anyone else’s) on every source-file, the same hazard --check reports as a wiped hook.
  • Keep the command byte-identical to what tma installs. Copy it out of tmux show-hooks -g | grep clear-attention rather than retyping it: --check compares 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:

variableeffect
TMA_BINThe 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_SOCKETPin 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_names lists the #{pane_current_command} basenames that flag a candidate pane. If your agent runs under a generic launcher (many run as node), add title_patterns to narrow the match by pane title, or rely on the hook registration below, which marks the pane regardless of process name.
  • [hooks].covers declares 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 a detail) or a lifecycle claim ({ lifecycle = "start" } / { lifecycle = "end" }). State routing is fixed: a manifest maps into idle/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_PATH is how tma inside the container finds the server. Passing TMUX through 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_PANE is the pane whose options get stamped. Every tma event in 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 creates 0700 and 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

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 $PATH for 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

scopegrantsgranted at pairing
readThe fleet, transcripts, receipts, and cards rendered but inert. Implicit: every paired device holds it.yes
act:answerapprove, deny, question_reply, question_reject.yes
act:steersteer, steer_now, interrupt, deny_with_message.yes
act:alwaysapprove_always, whose affirmative answer grants every following action of its class.no

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:

sequencekeyemulators
OSC 9osciTerm2, kitty, WezTerm
OSC 777osc_777Ghostty, WezTerm
OSC 9;4osc_progressGhostty, 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 = true writes 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’s monitor-bell) reacts.

  • The OSC sinks. osc = true (and osc_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 only allow-passthrough on in 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 doneidle 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, run tma ls --json once 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 --interval polling with nothing on stderr and no exit. Only latency changes. tma doctor is 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

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-right is identical to what is already drawn, so a static string sends your terminal nothing no matter how short status-interval is. A %H:%M clock 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 distinct tma status --session <name> is a separate long-lived job, so N attached sessions means N tma processes on every status-interval instead 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

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

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:

  1. ~/.tmux.conf
  2. $XDG_CONFIG_HOME/tmux/tmux.conf
  3. ~/.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:

tiermeans
3Hooks are on the hook path and a daemon is running. Nothing to improve; no reason line is printed.
2Hooks are wired but no daemon is running, so events stamp directly instead of going through the hub.
1The 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_at is 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. The watch table does exactly that with the context gauge.
  • Any tma invocation is a driver. A slow-timer tma status in 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 does tma ls, or a cron tma 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).

optionvaluemeaning
--manifest-dir<DIR>Load manifests only from this directory (test isolation).
--socket-name<NAME>Target a specific tmux server socket by name (tmux -L <name>).
--socket-path<PATH>Target a tmux server by socket path (tmux -S <path>), the form tmate and a hand-placed socket need (env TMA_SOCKET_PATH). Mutually exclusive with --socket-name: passing both is a usage error (exit 2).
--config<PATH>Load config from this path instead of ~/.config/tma/config.toml (env TMA_CONFIG). An absent file is the zero-config floor (all defaults).
-c, --client<NAME>The invoking tmux client for the picker/jump/watch Enter-jump. The run-shell jump bindings pass --client "#{client_name}" so the correct client is switched; absent, empty, or a still-unexpanded format (a binding context that does not expand, such as display-popup) falls back to targetless best-effort.
--debug-timingPrint cycle timing and producer/consumer/capture counts to stderr, including capture-skipped (panes that reused their stamp because their window produced no output since it). Only the poll surfaces (ls/status/jump) act on it.
-h, --helpPrint help.
-V, --versionPrint version.

The tmux binary itself is not a flag: it comes from TMA_TMUX_BIN, then [tmux] bin in config, then plain tmux. That is what points tma at a tmate socket or a second tmux build, whose servers refuse a mismatched client; see [tmux].

Selector flags

ls, status, jump, wait, act, subscribe, and watch share one vocabulary for saying which agents they are about. The flags are per-command (they sit after the subcommand), and they mean the same thing everywhere.

optionvaluemeaning
--session<NAME>Only agents in this tmux session.
--repo<NAME>Only agents whose pane resolves to this git repo.
--branch<NAME>Only agents on this branch (the literal HEAD when detached).
--agent<NAME>Only agents with this manifest name (e.g. claude).
--state<STATES>Only agents in one of these states, comma-separated.

Matching is exact string equality — no globbing, no case folding. Different flags AND together (--repo app --state blocked is blocked agents in app); --state’s comma-separated tokens OR within themselves. No flags means every agent, exactly as before.

--state takes the same tokens as wait --until: idle, working, blocked, unknown, and the pseudo-state done (idle plus attention: finished with output nobody has reviewed). done is the narrower half of idle--state idle matches a done pane too, --state done does not match a plain idle one. An unknown token is a usage error (exit 2) naming the valid set.

--repo matches the repo label the surfaces render, which is the origin repo’s name, so it selects a repo’s linked worktrees along with its main checkout; --branch is what splits them apart. A pane whose cwd resolves to no git repo matches neither flag (an unresolved repo is not a wildcard). Resolving those labels costs one memoized git call per unique directory, so status runs it only when --repo/--branch is actually present.

Filtering is display-only, and happens after the cycle. Every invocation still runs the full poll cycle and stamps every agent pane on the server; the selector narrows only what that invocation prints, counts, emits, jumps to, or (for act) acts on. A #(tma status --session X) driver refreshes the panes in your other sessions exactly as an unscoped one does.

Commands

commandsummary
versionPrint version and build information.
lsList agent panes, one tab-separated line each (--json for the versioned schema).
statusPrint the status-line one-liner: state counts with glyphs and #[fg=] styling.
jumpJump focus to an agent pane across sessions (--attention / --blocked / --next / --back / --home / --pane), or menu them (--menu).
attachHand this terminal to the session holding a pane (--pane %5): select its window and pane, then replace this process with tmux attach-session.
waitBlock until the target reaches one of --until’s states, then print the matched row(s). One pane, or a fleet (--all / --count).
actFire a guarded action into an agent pane (--all for every pane in scope), or enumerate/menu the fireable ones (--list / --menu).
receiptsRead the dispatch ledger act --slot writes: what a dispatch ended as, without dispatching to find out.
serveAnswer one remote connection over stdio: NDJSON requests in, NDJSON responses and events out. Spawned by an ssh forced command, not typed.
devicePair, grant, revoke and list the devices tma serve will answer. The whole write side of the remote scope model.
muteSuppress notifications for the panes in scope, for --for <DURATION> or until --clear.
subscribeStream the read path: one complete ls --json document per line, pushed when a daemon is present.
transcriptRead what the agent in a pane has been writing, as normalized events, newest first, a bounded page at a time.
watchPersistent live dashboard for a pane, window, or terminal of its own.
daemonRun the event-hub daemon in the foreground; --ensure spawns it if absent then exits.
reloadSignal the running daemon to hot-reload its config and manifests (SIGHUP).
initFirst-run setup: detect your installed agents and wire their hooks, install the keybindings, print the status-right line, then report with doctor.
install-hooksInstall, uninstall, or verify the agent and tmux hook wiring.
install-keysInstall, uninstall, or verify tma’s tmux keybindings.
doctorDiagnose each agent pane’s effective tier and why.
completionsPrint a shell completion script on stdout (tma completions zsh).
debugManifest-authoring and inspection tools.
eventInternal, unstable: bridge one agent hook event to a stamp.
clear-attentionInternal: clear the attention flag on the pane named and, when the tmux hook that fired it says a departure happened, on the pane just left; then nudge any resident tma watch. Invoked by the auto-installed tmux focus hooks. Navigation is not the only clear — the poll cycle also drops the flag on a pane a client is displaying once that client has been typed into after the flag went up.
superviseInternal: the detached-action supervisor. Spawned by the act broker’s detach path to hold the single-flight lock for the child’s lifetime, kill it at the deadline, then clear the lock and fire the completion notification. Never user-invoked.

event is invoked only through the tma-hook wrapper an agent’s config references, never by hand. debug stamp is likewise internal and unstable.

tma event authenticates nothing and is not meant to; the security boundary is your user account, spelled out in The security model.

tma ls

List agent panes.

Usage: tma ls [OPTIONS]
optionmeaning
--jsonEmit JSON ("schema": 1) instead of tab-separated lines.
--pane <ID>List only this pane id (e.g. %5), the single-row form.
selector flagsNarrow the listed rows.

--pane and the selector narrow the same way: no matching agent prints nothing and exits 0. A filtered --json is the same document with a shorter agents array, so a consumer parses it identically.

Rows come back in attention order, blocked → done → working → idle → unknown, then by session:window.pane. done is idle with the attention flag still set, a finished agent nobody has read. --json emits the same order in its agents array.

Plain output is one tab-separated line per agent pane, in this column order: pane, agent, state, detail, since, session:window.pane, title, attention, muted, repo, branch, worktree. The attention column is 1 when the pane still carries @agent_attention (finished or blocked output unreviewed), empty otherwise; the muted column is the same marker for a pane whose tma mute window has not expired.

The last three are the git labels. repo is the origin repo’s name, so a linked worktree reports the same name as its main checkout; branch is the current branch (the literal HEAD when detached); worktree is a 1-or-empty marker, set only for a linked worktree. All three are empty for a pane in no git checkout, which is the one case they cannot be told apart from a repo whose labels failed to resolve — use --json, where they are null, if you need the distinction. The JSON schema is documented in Pane options and JSON contracts.

tma status

Print the status-line one-liner: state counts with glyphs and tmux #[fg=] styling. As #(tma status) in status-right it is the required ambient driver: each status-interval run refreshes the stamped pane options and renders the counts.

Refreshing is a write, and tmux redraws every attached client in full on any option write, even an unchanged one. An idle agent costs nothing (the cycle reuses its stamp and writes no option); a working agent costs one redraw per cycle. The status string itself is cheaper and independent: tmux writes only the status row, and only when the expanded string changes, so a static status-right costs nothing and a %H:%M clock costs one row write per minute. Show agents in your status line has the details and the tmux version note.

Usage: tma status [OPTIONS]
optionmeaning
--format <FORMAT>Output form: tmux (default), plain, json, or prom.
selector flagsCount only the agents in scope.

Output is the fixed order blocked working done idle unknown, zero-count classes omitted, empty when there are no agents. Glyphs and colors come from [status] config.

The tmux form also wraps each class in #[range=user|tma:<class>]…#[norange], which tmux honors on a #() job’s output and which is what makes the counts clickable (Clickable status segments). The markers draw nothing and do nothing on their own; the other three formats carry no markup at all.

The counts are over the selected rows, which is what makes a per-session status line possible: #(tma status --session #{session_name}). See Show agents in your status line for the caveats before you wire one.

--format

One set of counts, four renderings. Every form runs the same cycle over the same selected rows, so which one you poll never changes what gets stamped: an external bar polling --format plain is as much an ambient driver as #(tma status) is.

formatoutput
tmuxThe default status-line one-liner, glyphs with #[fg=] styling plus the clickable-range markers. No trailing newline.
plainThe same glyphs and counts with the color codes dropped, for a bar that applies its own styling. No trailing newline.
json{"schema":1,"counts":{"working":N,"blocked":N,"idle":N,"unknown":N,"done":N}}, one line.
promPrometheus text exposition, for a node_exporter textfile collector.

plain honors the configured [status] glyphs; only the colors go away. Both one-liners omit zero-count classes and print nothing at all when there are no agents.

json is the opposite: every class is present even at zero, so a consumer never branches on a missing key. done and idle are disjoint counts — an idle pane with unreviewed output is counted under done and not under idle, so the five always sum to the number of panes in scope. That is the split the rendered line has always shown, and it is deliberately not the same as the done key on a JSON row (see tma ls), which is a subset of state: "idle" because the row keeps its stored token.

prom emits two gauge families, each with its own HELP/TYPE comments:

# HELP tma_agents Agent panes in each state class. The classes are disjoint: ...
# TYPE tma_agents gauge
tma_agents{state="working"} 2
tma_agents{state="blocked"} 1
tma_agents{state="idle"} 0
tma_agents{state="unknown"} 0
tma_agents{state="done"} 1
# HELP tma_agent_state_seconds Seconds the pane has held its current state ...
# TYPE tma_agent_state_seconds gauge
tma_agent_state_seconds{pane="%5",agent="claude",state="blocked"} 42.000

tma_agents carries all five classes even at zero, so a series never disappears mid-scrape. tma_agent_state_seconds is one series per agent pane, from the row’s since against now; a pane whose transition was never stamped reports 0. Its state label uses the same disjoint classes, so summing the per-pane series by state reproduces tma_agents exactly. The textfile-collector recipe is in Drive an external bar.

tma jump

Jump focus to an agent pane across sessions. At most one direction flag is used; --next is the default when none is given.

Usage: tma jump [OPTIONS]
optionmeaning
--attentionJump to the next agent that wants you: blocked first (longest-blocked first), then finished-unreviewed (idle with attention). Advances from the current pane and wraps.
--blockedJump to the longest-blocked agent.
--nextJump to the next agent after the current pane (session, then window, then pane order).
--backReturn one step along the trail (the previous jump’s origin).
--homeReturn to the oldest recorded origin (the bottom of the trail) and clear the trail.
--pane <ID>Jump to this pane id. Records the origin like any forward jump and clears the pane’s attention flag; ignores the selector (the target is already named). A pane with no agent on it is a note on stderr and exit 0.
--menuRender a tmux display-menu of every agent (the pane you invoked it from excluded), each entry firing jump --pane on that agent. Needs an attached client; an empty list prints “no agents” and exits 0.
selector flagsScope the candidates a forward jump may land on.

The selector scopes triage: tma jump --attention --repo app walks only that repo’s waiting agents, and reports “no agents waiting for you in scope” when it finds none. --back/--home replay the return trail and ignore it.

A forward jump (--attention/--blocked/--next) pushes the current location onto a per-client return trail. --attention and --blocked also clear the destination pane’s attention flag (focusing a waiting agent reviews it); --next is plain positional cycling and leaves attention untouched. --back pops one entry; --home returns to the trail’s bottom entry and empties it. The trail is a bounded stack (cap 8, oldest dropped past the cap) held in a per-client server option, so --back/--home are independent per client. When the trail is empty they print a note and exit 0.

Pass --client "#{client_name}" (a global option) from a run-shell binding, which format-expands it, so the jump switches the client that pressed the key and keys its return trail by it.

--menu is the tmux-native counterpart of the picker: entries are ordered like the picker’s list (blocked, done, working, idle, then longest-in-state first), the first nine carry a 1-9 quick-select digit, and each one runs tma jump --pane <id> with the acting client and the invoking server resolved into the command. It is what a right-click on a clickable status segment opens.

tma attach

Hand the terminal you are sitting at to the tmux session a pane lives in. It selects the pane’s window and pane on that session, then replaces this process with tmux attach-session -t <session>, carrying --socket-name / --socket-path through, so what comes back is a real tmux client showing the pane you named.

Usage: tma attach --pane <ID> [--print]
optionmeaning
--pane <ID>The pane to land on (e.g. %5). The only target: the handover replaces this process, so there is nothing left afterwards for a selector to have narrowed.
--printPrint the tmux attach-session argv instead of running it. The window and pane are still selected. Needs no terminal of its own, since it hands one over to nothing.

This is the half jump cannot do. tma jump --pane is switch-client, which moves a client that is already attached. From a terminal that has none (a fresh ssh session, a phone’s terminal app) there is nothing for it to move.

Inside tmux, attach is jump. When $TMUX is set there is already a client here, and replacing it with a nested one is never what anybody means, so the command runs exactly tma jump --pane <ID>: same origin trail, same attention clear, same “no agent in pane” note. --print then has no argv to show and says so on stderr.

It refuses without a terminal. tma attach hands its own tty to tmux, so a run whose stdin is not a terminal (a pipe, a run-shell, cron) exits 2 before moving anything, rather than letting tmux fail with its own terse line after the selects have already changed somebody’s focus. --print is exempt.

Exit codes:

codemeaning
0attached (the exec does not return), or --print printed the argv
2stdin is not a terminal, so there is no tty to hand over
3the pane vanished, whether before the selects or between them and the attach
1a runtime failure (no tmux binary, an attach tmux refused)

tma wait

Block until the target reaches one of --until’s states, then print the matched row(s) and exit. It is the scripting primitive: a tier-2 poll loop (immediate first cycle, then roughly one-second ticks with config and manifest hot-reload), level-triggered, so an already-in-state target returns immediately. A transient tmux stall is ridden out as a skipped tick (a one-time stderr note flags it), not a failure; a vanished server still ends the wait.

Usage: tma wait [OPTIONS] --until <STATES>

One target, either an explicit flag or the selector’s --agent. The explicit flags are mutually exclusive:

optionmeaning
--pane <ID>Wait on this specific tmux pane id (e.g. %5). Its disappearance while waiting is exit 3.
--agent <NAME>Wait on the agent pane with this name. Pins to the first in-scope pane observed, then behaves as --pane on it (a vanish is exit 3). Matching more than one in-scope pane at that first observation is an error suggesting --pane, never a silent first-match.
--anyWait on any agent pane in scope; the first to reach a target state (in surface-sort order) wins. --any never pins and keeps waiting on a vanish.
--allBarrier: succeed only when EVERY agent pane in scope is in a target state at once.
--count <N>Quorum: succeed once at least N agent panes in scope are in a target state.

Naming no target at all is a usage error (exit 2). --agent is a selector flag that doubles as a target, so it combines with the other four: --all --agent claude is a barrier over every Claude pane, and --any --agent claude is the first Claude pane to land.

Membership. --all pins its membership at the first observation and --count never pins. A barrier is over a fleet you already have: a pane that launches mid-wait does not join it (and so cannot hold it open forever), while a member whose pane dies ends the wait at exit 3, exactly as a --pane vanish does. A quorum is over whoever shows up: it re-reads the scope every cycle, so a pane appearing mid-wait counts toward N and one leaving is not an error. An --all whose scope matches no pane at that first observation is exit 2 — a barrier over an empty fleet would be vacuous success. --count stays permissive: it waits for N matches among however many panes appear, so a scope that cannot yet reach N simply blocks until --timeout.

Other options:

optionmeaning
--until <STATES>Required. The state(s) to wait for, comma-separated: idle, working, blocked, unknown, and done (idle plus attention, the finished-and-unreviewed surface). wait returns as soon as a cycle observes the target in any of them. done is by definition unreviewed: once you have the pane on screen and type at it, the mark comes down and does not come back for that episode, so a wait STARTED after that never satisfies. A wait already running is not robbed by its own poll — its cycle evaluates the goal before applying that clear — but wait on idle when a human is at the keyboard and may review the pane before your script gets there.
--since <EPOCH_MS>Only a state that BEGAN after this epoch-ms timestamp satisfies (the row’s episode_ms must be strictly greater). Works with every target. A done target also satisfies on a fresh completion of a pane that never left idle: a second turn end moves @agent_turn_at while since_ms stays pinned to the start of the idle run, and episode_ms is the later of the two. Feed the row’s own episode_ms back as the next floor, not since_ms: since_ms is a floor the row already clears, so the wait would re-satisfy on every lap.
selector flagsScope --agent/--any/--all/--count. --agent is both the scope and the by-name target, so the flag that names an agent is the flag that selects it. Rejected alongside --pane, whose id is already unique (exit 2).
--timeout <SECS>Give up after this many seconds and exit 124 (the timeout(1) convention). Absent waits forever; compose with timeout(1) for an external belt.
--jsonEmit the matched row as one schema-1 JSON object (same keys as an ls --json row) instead of the tab-separated line. --all/--count emit the schema-1 agents document of the satisfied set instead.

--since is the escape hatch from level-triggering. A supervisor loop that waits for blocked, acts, and loops would otherwise re-satisfy immediately on the same episode, because the state it waited for is still the current one; passing the episode_ms of the row it just handled requires a NEW transition — or, for done, a new turn end on a pane that has stayed idle throughout. The recipe is in Block a script on agent state.

--pane on a pane that exists but is not yet an agent blocks forever by design (the agent may launch later); a one-time stderr hint flags a likely typo without breaking scripts. Once a watched pane HAS been seen carrying an agent, the same situation means the opposite thing: the agent process is gone while the pane lives, so the wait ends at exit 4 naming the pane instead of blocking to a timeout that could not tell a crashed agent from a slow one. That applies to --pane, a pinned --agent, and --all members; --any and --count ignore a departure and keep waiting for the others.

Exit codes

codemeaning
0A target state was observed (the row on stdout; --json for one schema-1 object, or the agents document under --all/--count).
124Timed out (--timeout elapsed); nothing on stdout.
3A watched pane vanished while waiting: a --pane, a pinned --agent, or an --all member. --any and --count keep waiting for the others.
4The agent died while its pane lived on: a watched pane that HAD an agent row lost it. Same targets as 3; the message names the pane.
2Usage error (bad --until token, no target named, an invalid target combination, or --all whose scope matched no pane).
1A generic runtime failure (ambiguous --agent at first observation, or no tmux server).

tma act

Fire a guarded action into an agent pane, or enumerate the fireable ones. One verb, three modes: fire <name>, --list, or --menu. Actions are declared in manifests (see Action manifest schema); the broker re-verifies the target’s state, holds a single-flight pane lock, then acts. To author one, see Author a custom action.

Usage: tma act [OPTIONS] [NAME]

[NAME] is the action to fire; omit it with --list / --menu.

optionmeaning
--pane <ID>Target this pane id (e.g. %5); defaults to the current pane inside tmux. Rejects the selector flags (exit 2), whose narrowing a pane id has already done.
selector flagsScope the target. Alone they must resolve to exactly one pane: none is exit 3, more than one is exit 1 naming the candidates (--agent <NAME> is the common form). With --all the whole selection is the target set.
--allFire on EVERY selector-matched pane, one after another.
--dry-runPrint the resolved targets and each one’s gate verdict; execute nothing, acquire no lock. For a single target it also prints the resolved context (with each value’s age) and the would-be keys or command.
--arg <VALUE>Repeatable. Pass a value to an exec action’s command as environment (TMA_ARG, TMA_ARG_1..N, TMA_ARG_COUNT); never interpolated into the command string. Every other kind rejects it (exit 2). Under --all every target gets the same values.
--text <STRING>The string a text action delivers into the pane, literally and as one line. Required for a text action, rejected by every other kind (exit 2). The token after it is always taken as its value, so a message starting with - needs no quoting games.
--forceSkip the when gate only, never requires and never the lock.
--expect-episode-ms <MS>Refuse (episode-changed, exit 4) unless the pane is still in this episode: the episode_ms of the tma ls --json row you acted on. Checked inside the action lock. A usage error alongside --all (exit 2). See Binding a dispatch to the pane you saw.
--expect-permission-request <ID>Refuse (request-gone, exit 4) unless the pane still carries this @agent_permission_request: the permission_request of that same row. Checked inside the same lock. A usage error alongside --all (exit 2).
--slot <ID>Dispatch at most once under this id. The first fire writes a receipt into the host ledger; a retry with the same id replays that receipt, exits with its code, and sends nothing. A usage error alongside --all (exit 2). See Retrying a dispatch safely.
--device <NAME>Record which device dispatched, on the slot’s receipt. Requires --slot and is never part of the slot’s identity, so another device’s retry still replays the first one’s receipt. A usage error alongside --all (exit 2).
--yesSatisfy a confirm action non-interactively (a non-TTY without --yes refuses). Under --all it covers the whole batch.
--jsonEmit schema-1 JSON: the fire result object (the results envelope under --all), or the --list document.
--listEnumerate actions; with --pane, include each one’s fireability verdict.
--menuRender a tmux display-menu of the currently-fireable actions (the keyboard-only parity surface, wired by tma install-keys).

The --json result object, the --all envelope, and the --list document are specified in Pane options and JSON contracts.

A keys action can also carry a per-agent [hook] arm. On a claude pane whose hook reply lane is holding a prompt open, approve and deny hand the verdict to that hook rather than sending the key sequence: outcome replied, exit 0, kind hook in the audit log. With no hook holding, the same fire sends the keys as it always did. --dry-run names which of the two it would take.

Binding a dispatch to the pane you saw

A surface that reads a pane, shows a person the prompt, and dispatches their answer some seconds later is answering a pane it can no longer see. If the agent asked a second question in between, the approve meant for the first one lands on the second. The --expect-* flags close that: a script reads episode_ms and permission_request off the tma ls --json row it acted on and hands them back on the fire.

episode=$(tma ls --json | jq -r '.agents[] | select(.pane == "%5") | .episode_ms')
# ... a person looks at the prompt and decides, some seconds later ...
tma act approve --pane %5 --expect-episode-ms "$episode"

Both are checked inside the pane’s single-flight action lock, against the same read the gate is re-asserted from, so nothing can turn the prompt over between the check and the keystrokes. A pane that has moved on refuses episode-changed; one that no longer carries the quoted request id refuses request-gone. Both exit 4 and send nothing. (request-gone on a vanished outcome is a different event, exit 3: the API server’s own 404. The outcome field separates them.)

Two limits are worth knowing. The comparison is equality, not order, so a backward wall-clock step can leave the pane at an earlier episode than the one you read and that refuses episode-changed too, which is the honest answer: the pane is not where you saw it. And a matching permission_request is a necessary condition, not proof the prompt is still open, because tma clears the stamp only when its own reply lands or the agent’s next event arrives; a matching id can still name a request that has already been answered.

--dry-run reports the gate verdict and never fires, so it does not evaluate either expectation.

Steering (--text)

tma act steer --pane %5 --text "use the existing helper instead of a new one"
tma act steer_now --pane %5 --text "stop and rebase onto main first"

steer sends one line to an idle agent; steer_now sends the same line to a working one, where the agent queues it. They are separate actions rather than one with a wider gate, because the second is only offered for agents that declared they queue a mid-turn message rather than losing it. Neither is confirm = true, so a script can drive them; neither is offered in the --menu, which has nowhere to ask for the string.

The string reaches the pane exactly as typed. --text Enter types five characters and presses nothing; --text C-c types three and interrupts nothing. Before any tmux command runs, the host refuses a payload that is empty, over 4096 bytes, carries a control byte (a steer is one line), or begins with one of the action’s sigils (/ and ! by default), so a caller cannot reach /clear or /compact through a message. Each refusal is exit 4 with its own reason token (empty, too-long, control-bytes, sigil), and delivers nothing. The rules and the manifest side of steering are in Action manifest schema.

Retrying a dispatch safely

A caller that dispatches over a network cannot tell “the action never ran” from “the response never arrived”. A phone that suspends mid-request, or an ssh connection that drops, leaves the sender with no answer and exactly one bad option: send it again, and maybe approve twice.

--slot <ID> closes that. The caller invents an id for the dispatch it means to make, and tma dispatches at most once per id:

tma act approve --pane %5 --slot "approve:%5:$episode" --device phone
# ... the response is lost; the phone reconnects and sends the same line ...
tma act approve --pane %5 --slot "approve:%5:$episode" --device phone

The second invocation prints the first one’s receipt, exits with the first one’s code, and sends nothing. In --json it carries "cached": true (a fire that actually happened carries "cached": false, so a slotted caller always finds the key); in text it says cached receipt for slot ... on stderr.

The id is opaque to tma and it is the whole identity, so make it name the dispatch you mean: the action, the pane and the episode you read off the row, not just the action. Reuse an id for a different action and you get the first dispatch’s receipt back, which is what “at most once per id” means.

Three rules are worth knowing before you build on it.

  • A locked refusal (exit 5) releases the slot, because it is the one refusal that changed nothing and will pass on a retry. Every other outcome writes a terminal receipt, error included: a broker failure cannot prove the keystroke did not land, so its receipt records fired-unknown and a retry replays it rather than sending a second time.
  • A receipt answers for 24 hours. Entries are evicted by size (the newest 4096 dispatches), never by age below that floor, because a short-lived receipt turns a late retry back into a genuine second fire.
  • The ledger is one file per host, 0600 under tma’s runtime directory, shared by every process on the machine. Two devices and two connections land on the same slot, which is what makes the retry idempotent rather than per-connection. A record that cannot be parsed refuses (exit 1) instead of guessing: tma names the file so you can remove it.

--dry-run fires nothing, so it claims no slot.

Fan-out (--all)

--all resolves its targets from one cycle, then runs the ordinary per-pane broker sequence on each in turn: every target takes its own single-flight lock and re-verifies its own gate at fire time, so a fan-out is N independent fires, never a shortcut around the guards. One target’s refusal does not abort the rest.

  • A confirm action asks once for the batch, listing the panes, rather than once per pane; --yes satisfies the batch.
  • --json emits the results envelope, and it does so even when one pane matched, so a script’s parse does not depend on the match count.
  • The exit code is the WORST target’s, ranked: acted, locked (5), a gate refusal (4), vanished (3), timeout (124), a failed exec child (its own code), a broker error (1). A fan-out exits 0 only if every target acted.
  • A selector that matches no pane is exit 2, not a silent no-op.
  • Every fire in the batch shares one batch id in the act audit log, so N lines of one fan-out are distinguishable from N separate invocations.

What --all is for, and what it is deliberately not. It is a fleet convenience for the two actions you mean across a whole selection at once: interrupt and deny. It is not a unified permission inbox, and tma will not grow one. Batch approval is an exploited surface, not a hypothetical one: WorkOS documented attackers embedding a dangerous operation inside a batch of benign ones and using phrases like “don’t bother reviewing each one” to discourage individual review (2026-08-05, https://workos.com/blog/approval-fatigue-agent-governance), and the same pattern is tracked as a threat rule. The local failure mode is smaller and just as real: an action fires the keys its manifest declares, and one mis-typed dialog turns an approve into something else, which is a bug tma has actually shipped and fixed. Multiply that by every pane the selector matched. --all stays because interrupting a fleet is a real need; approving one is a decision you make one prompt at a time, which is what the picker and the action menu are for. See The security model.

The act audit log

[act] log appends one JSON line per fired action, whatever the outcome:

[act]
log = "~/.local/state/tma/acts.jsonl"

Unset by default. The parent directory is created for you, ~ is expanded, and the file is created 0600 and appended to, never rewritten, exactly like notify.log. The natural place for it is beside that file. A log that cannot be written is skipped silently: an audit record must never turn a delivered action into a failure. Nothing rotates it; that is logrotate’s job, or truncate’s.

The key set, in order:

keytypemeaning
schemanumberact-log schema version (1), versioned separately from the --json result
atnumberepoch ms the fire completed
panestringtarget pane id
agentstring | null@agent_name as read under the lock; null when the pane vanished before any read
actionstringthe action name
kindstringkeys, api, hook, text, or exec: the transport the fire used, not just the manifest kind
outcomestringthe --json outcome vocabulary
reasonstring | nullthe refusal or vanish token, null for every other outcome
sourcestringwhich surface asked: cli (a person at a TTY), cli-yes (--yes, or no TTY to prompt on: a script, a hook, an agent), menu (the tmux action menu)
episode_msnumber | nullthe pane’s episode instant (max(@agent_since, @agent_turn_at)), read under the lock
repeatnumberconsecutive fires of this action on this pane in this episode, counting this one; 0 when the fire never reached the effect
pending_toolstring | null@agent_pending_tool: which tool the open prompt is about
pending_callstring | null@agent_pending_call: its call id
allbooleanwhether the fire came from --all
batchstring | nullthe id shared by every fire of one --all invocation

source is the field that makes the log worth keeping. Peers agree it is the load-bearing one: Claude Code’s claude_code.tool_decision records a source alongside the decision (config, hook, user_permanent, …) and Codex’s codex.tool_decision records the configuration source the same way. A line that says only “approved” cannot tell a human at a menu from a script from an agent shelling out to tma act, which is precisely the question a tool with several fire surfaces raises.

What the line never carries: no key, no token, no pane title, and no agent-supplied prose. @agent_pending_summary is a command line or a path that an agent chose, so the log names the pending call (pending_tool, pending_call) and never quotes it. That is the same digest rule notify.log follows, for the same reason: this is the file most likely to be pasted into an issue.

jq -r 'select(.outcome=="sent") | "\(.at) \(.source) \(.action) \(.pane)"' \
  ~/.local/state/tma/acts.jsonl

The repeat warning

Three consecutive fires of the same action on one pane inside one episode print a line to stderr and land as repeat: 3 in the audit log:

tma: 3 consecutive approve on %5 in this episode; the agent may be re-asking

It never refuses. The threshold is the vendors’: Claude Code’s auto mode pauses when its classifier “blocks an action 3 times in a row” (https://code.claude.com/docs/en/permission-modes) and Codex’s auto-review circuit breaker aborts the turn at three consecutive denials. Both of those stop an agent; tma is telling a person something instead, because the thing worth noticing is that the same prompt keeps coming back, or that a finger keeps answering it. A new episode, or a different action, starts the run over. The run lives on the pane as @agent_act_repeat.

Exit codes

codemeaning
0Acted: keys or a text string delivered, an API-channel answer delivered (2xx), a hook-lane verdict written, a synchronous exec child exited 0, or a detached supervisor spawned.
124A synchronous exec child was killed at timeout_ms.
4The gate refused: state did not satisfy when, requires was unmet (including an API permission-reply op with no pending request id or no resolvable endpoint), the action does not apply to this agent, or the gated metric has no coverage. Also the two binder refusals, episode-changed and request-gone, when the fire carried an --expect-* the pane no longer satisfies (see Binding a dispatch to the pane you saw). The refusing fact goes to stderr.
5The pane action lock is held by another invocation.
3The act’s target disappeared mid-act: tmux reports the pane gone (can't find pane / no such pane), reason pane-gone; or the permission was answered or withdrawn between the gate and the act (an API 404, or a hook-lane verdict file that already exists), reason request-gone — the pane itself is still there.
2Usage error (bad flag combination, selector flags alongside --pane, or --all whose selector matched no pane).
1A runtime failure (no tmux server, a broker error, or an ambiguous selection without --all). A tmux command the server refused lands here, with tmux’s own stderr in the message — only a pane tmux reports as gone is exit 3. A --slot dispatch whose ledger cannot be read is also 1, and nothing is dispatched.

Under --all the code is the worst target’s on the ladder above (see Fan-out).

The reserved band (3, 4, 5, 2) is strictly pre-spawn broker verdicts. An exec action that did spawn passes its child’s own exit code through verbatim, so a child code can land inside that band; scripted consumers that branch beyond success/failure read the --json outcome field, which is authoritative.

tma receipts

Read the dispatch ledger tma act --slot writes. This is how a caller learns what a dispatch ended as when it lost the response, without dispatching the action again to find out. It reads one local file: no tmux, no pane lock, no keystroke.

Usage: tma receipts [OPTIONS]
optionmeaning
--slot <ID>Only the receipt for this slot id.
--since-ms <MS>Only dispatches claimed at or after this epoch-ms instant (inclusive).
--jsonEmit the schema-1 document instead of one line per receipt.

Text output is one tab-separated line per receipt, oldest first, with - for an absent field so the column count never varies:

at_ms   slot   pane   action   outcome   reason   exit_code   device

--json emits {"schema":1,"receipts":[...]}, each element carrying slot, pane, action, device, at_ms, outcome, exit_code and reason.

A dispatch still in flight has no receipt and is not listed. Entries stay until the size cap evicts them, so a receipt older than the 24 h TTL can still be listed even though a fresh dispatch on that slot would fire again; at_ms is what says which. Exit 0 (an empty result is not an error), or 1 when the ledger is torn.

tma serve

Answer one remote connection. NDJSON requests on stdin, NDJSON responses and events on stdout, logs on stderr; nothing but frames reaches stdout.

Usage: tma serve --stdio --device <ID>
optionmeaning
--stdioSpeak the protocol over stdin and stdout. Required today. It is a flag rather than the implicit default so a future transport can be added without changing what a bare tma serve means.
--device <ID>Which paired device this connection belongs to, as tma device pair recorded it.

This is spawned, not typed. One process per connection, started by an ssh forced command:

command="tma serve --stdio --device SHA256:0Mn3XQvC…",restrict ssh-ed25519 AAAAC3Nza… phone

sshd authenticates the caller and passes its id; serve trusts that argument and nothing in the stream. The handshake frame names a device too, and that field is the client’s own claim, used for the host’s log line and never for authorization. Serve tma over ssh is the recipe, and the remote wire protocol is what the two ends say to each other.

There is no listening socket, no TLS and no bearer token, because the ssh channel is the transport. What a connection may do is read from the device store on every request and every publish, so tma device revoke reaches a connection that is already open: its next request is refused scope-denied, its event stream stops, and the process exits.

Every serve connection runs its own detection cycle, so connections cost tmux query throughput. [serve] max_connections caps them, four by default, and the next dial is refused with a typed too-many-connections error rather than accepted and starved. [serve] reconcile_interval_ms is both the stream’s poll cadence and the freshness number the handshake quotes.

Exit 0 on EOF or SIGTERM, 2 when the connection is refused (an unknown or revoked device, or the cap), 1 when the host could not start.

tma device

Pair, grant, revoke and list the remote devices tma serve will answer. This is the whole write side of the scope model: grants are CLI-only. No device can widen its own grants, no protocol frame asks for one, and there is no in-app approval prompt. A device that wants more is told to ask the person at the terminal.

Usage: tma device pair <NAME> --id <ID> [--scope <SCOPE>]... [--only]
       tma device grant <NAME> <SCOPE>
       tma device revoke <NAME>
       tma device list [--json]
optionmeaning
--id <ID>The opaque id the spawner will pass as tma serve --device. For SSH that is the key’s fingerprint, which ssh-keygen -lf <key.pub> prints.
--scope <SCOPE>Grant this scope on top of the defaults, at pairing time. Repeatable.
--onlyOn pair, grant read plus whatever --scope names, instead of the defaults. Bare, this is the watch-only device: it sees the fleet and can answer nothing.
--jsonOn list, emit the schema-1 document instead of one line per device.

The four scopes

scopegrantsgranted at pairing
readThe fleet, transcripts, receipts, and cards rendered but inert. Implicit: every paired device holds it.yes
act:answerapprove, deny, question_reply, question_reject.yes
act:steersteer, steer_now, interrupt, deny_with_message.yes
act:alwaysapprove_always, whose affirmative answer grants every following action of its class.no

act:always is never granted by default and never by a device asking. It takes tma device grant <name> act:always, typed on the host. In the other direction, tma device pair <name> --id <id> --only grants read alone: a tablet you want to watch with and never answer from.

A dispatch naming an action outside that table is refused, compact and any action you wrote yourself included. The remote vocabulary is closed on purpose: a phone reaching /compact is exactly the control-plane access the scopes exist to withhold.

Pairing

$ ssh-keygen -lf ~/phone-key.pub
256 SHA256:0Mn3XQvC… phone (ED25519)

$ tma device pair phone --id SHA256:0Mn3XQvC…
paired SHA256:0Mn3XQvC… as phone
  scopes: read, act:answer, act:steer

The record lands in ~/.config/tma/devices.toml, mode 0600, written by atomic rename. Serve tma over ssh is the rest of the recipe: the authorized_keys forced command that turns a dial into a connection.

Revocation

tma device revoke <name> removes the record. Every live serve process re-reads the store per request and per publish, so a revoked device’s next request is refused and its event stream stops and the process exits, without waiting for it to hang up. Removing the authorized_keys line stops the next dial and nothing else, which is why the record is the authority and the line is the courtesy; revoke prints the line to remove.

Exit 0, 2 on usage, 3 when no device answers to that name, 1 when the store could not be read or written.

tma mute

Stop a pane from notifying, without changing anything tma detects about it.

Usage: tma mute [OPTIONS]
optionmeaning
--pane <ID>Mute this pane id (e.g. %5); defaults to the current pane inside tmux.
--for <DURATION>Stay muted this long. Without it the mute holds until --clear.
--clearLift the mute on the matched panes.
selector flagsMute every pane in scope.

The duration grammar is an integer plus an optional unit: s seconds, m minutes, h hours, d days, with a bare number read as seconds (tma mute --for 90 is 90 seconds). Anything else is a usage error, as is 0 — a mute that is over before it starts — and --for alongside --clear.

Targets resolve the way tma act’s do, minus the --all opt-in: a selector mutes every pane it matches, because a mute is per-pane, idempotent, and undone by one --clear. --pane and the selector flags are mutually exclusive.

What mute changes is the fire, nothing else. A muted pane is still detected, still stamped, still counted by tma status, still blocked in tma ls and in the JSON — it simply rings nothing: no display-message, no bell or OSC, no [notify] command, for the state triggers and for context_high and stall. The episode’s @agent_notified_at marker is written as usual, so a mute that expires mid-episode does not then ring for a transition you already muted. A detached action’s completion notification is deliberately outside the mute: you asked for that one, and it reports once.

The deadline lives in the pane option @agent_mute_until (see Pane options), which is what makes a mute survive a tma restart, a daemon stop/start, and a config reload; --json rows carry the resolved muted boolean.

codemeaning
0The option was written (or unset) on every target.
3The selector matched no agent pane.
2Usage error (bad --for value, --for with --clear, selector flags alongside --pane, or no target and not inside tmux).
1A runtime failure (no tmux server, or a tmux command the server refused).

tma subscribe

Stream the read path. One long-running process emits one complete ls --json schema-1 document per line (the same document tma ls --json prints), snapshot semantics with no diffs. It replaces a consumer’s own polling timer: a Stream Deck plugin or dashboard spawns tma subscribe --json and re-renders on each line, holding a connection to nothing but the tma binary. The recipes are in Stream state changes.

Usage: tma subscribe [OPTIONS]
optionmeaning
--jsonRequired. JSON is the only emission today; a missing --json is a usage error (exit 2).
--interval <SECS>Poll cadence when no daemon is present, and the degrade cadence when one dies (default 1). Push mode delivers on the daemon’s edge, so this only bounds the daemonless path. Must be at least 1.
--changes-onlySkip a poll-mode emission that would repeat the last document.
--eventsEmit one edge record per state transition instead of snapshots.
selector flagsEmit only the agents in scope. Each line stays a complete schema-1 document with a narrower agents array; the emission cadence and the push/poll contract are unchanged.

Push, poll, and what the stream promises

With a daemon running, subscribe rides its edge pushes (the same wake-hint subscription tma wait uses): a state change wakes the stream, which runs its own poll cycle and emits what that cycle observed, well under --interval. Wake hints arriving within a 100 ms window coalesce into one emission, and a slower belt cycle emits only when it observes a change, so a quiet system emits nothing after the first snapshot. Every emitted document is built from the subscriber’s own cycle, never from the socket, so push and poll output are identical — and so are --changes-only and --events, which diff the same cycles either way.

Degrade is invisible except as latency: no daemon, a daemon dying mid-stream, or a daemon too old to answer the subscribe frame all drop the stream to unconditional --interval polling, and a periodic re-probe picks a returning daemon back up. There is no heartbeat — process death is the liveness signal, so a consumer that owns the process respawns it on EOF. The stream exits only on a signal or when its stdout closes; it prints one JSON document per line to stdout and nothing else there.

Four things the stream deliberately does not do:

  • No replay. A subscriber sees what happens from the moment it starts. There is no backlog, no cursor, and no way to ask for what you missed while your consumer was restarting.
  • The first line is the current snapshot, not an event: in the default mode it is the full document as of the entry cycle, and under --events there is no first line at all (see below).
  • Coalescing loses intermediate states. Pushes inside the 100 ms window collapse into one cycle, so a pane that went workingblockedworking faster than that emits nothing at all. The stream is level-triggered on each cycle’s observation, not a log of every instant.
  • The poll degrade is silent. Nothing is printed to stderr and the stream does not exit; only latency changes. If you need to know which mode you are in, tma doctor reports whether a daemon is running.

--changes-only

In poll mode the stream emits every --interval whether or not anything moved, which is the pre-daemon self-poller contract: a consumer that just re-renders does not care. A consumer that appends does — a daemonless logger writing to a file gets 86,400 identical lines a day. --changes-only makes the poll tick behave the way the push-mode belt already does: emit only when the document differs from the last one sent.

It is a no-op in push mode (those wakes are already edges) and under --events (edges are change-triggered by construction), accepted silently in both so a script does not have to know which mode it landed in. The entry snapshot is always emitted.

--events

Instead of snapshots, emit one record per state transition, one JSON object per line:

{"schema":1,"at_ms":1700000000000,"pane":"%5","agent":"claude","from":"working","to":"blocked","detail":"permission","locator":"work:1.0","repo":"app","branch":"main"}
keymeaning
at_msWhen the stream observed the transition (the diffing cycle’s clock), not necessarily when the agent changed. Coalescing and the poll interval both sit between the two.
from / toThe state on each side, in the selector vocabulary: idle, working, blocked, unknown, done.
detail, locator, repo, branchThe same values the row carries after the transition (null where the row’s are).

The states are the disjoint reading: a finished-but-unreviewed pane is done, not idle, so setting the attention flag on an idle pane is a real idledone edge, and anything that clears attention is doneidle: jumping to the pane, navigating off it, or typing at it while it is on your screen. Read that edge as “the user saw it”. The stream emits the idledone edge before applying its own clear, so a completion is always reported at least once, even when the keystroke that retires it landed before the stream first saw the mark.

Two edges have an open end, spelled as the empty string:

  • A pane that appeared since the last cycle emits "from": "".
  • A pane that vanished emits "to": "", carrying the fields from the last row seen.

The empty string, rather than unknown, is what makes those distinguishable: a pane genuinely can be observed in unknown (it is there, its agent’s state is unreadable), and a consumer must be able to tell that from “there was no pane”.

A pane whose state did not change emits nothing, even if its detail or title did — this is a transition stream, not a change feed.

There are no synthetic edges for the initial snapshot. The first cycle establishes the baseline silently; the first line you see is a real transition. A consumer starting fresh has no prior state to reconcile, and inventing ""working edges for panes that have been running for an hour would misdate them. If you need the current state at startup, run tma ls --json once before (or alongside) the stream.

With a selector, rows are filtered before the diff, so a pane leaving the selection looks like a departure and one entering it looks like an appearance. tma subscribe --json --events --repo app is a clean per-repo event feed as long as you read it that way.

The jsonl logging recipe is in Stream state changes.

tma transcript

Read what the agent in a pane has been writing. Every state surface above tells you that a pane is blocked; this one tells you what it was doing when it stopped, out of the agent’s own transcript store. The events are normalized across stores, so a claude pane and an opencode pane answer in the same vocabulary.

Usage: tma transcript --pane <ID> [OPTIONS]
optionmeaning
--pane <ID>Required. The agent pane to read (e.g. %5).
--last <N>How many events to return, counting back from the newest (default 50).
--before <CURSOR>Return only events older than this cursor: the older a previous page reported.
--headersDrop bodies and cap every string at the header budget (256 bytes). Without it a local run carries each event’s body inline.
--event <CURSOR>Fetch one event’s body instead of a window. Excludes --last, --before and --headers.
--subagent <ID>Read a nested agent’s own transcript (the child_id a subagent_ref event carries). Claude only.
--jsonEmit the schema-1 document instead of one tab-separated line per event.

Text mode is one line per event, newest first: kind, the store’s own timestamp, and the first line of the body.

$ tma transcript --pane %0 --last 5
user_message     2026-01-01T00:00:11.000Z  can you check the failing test
compaction       2026-01-01T00:00:10.000Z  compact_boundary
turn_boundary    2026-01-01T00:00:09.000Z  end turn_duration
bookkeeping      2026-01-01T00:00:08.000Z  mode
attachment       2026-01-01T00:00:07.000Z  file (64 bytes)
tma: more before this page: --before t1.1000013.a3e191d3.e36.8c6.0

The older line goes to stderr, so a pipe gets only the events.

Which agents are served

Five stores are read: claude, codex, gemini, pi and OpenCode. cursor-agent is refused by name rather than served empty (store-incomplete); Agent transcript stores is the argument for why, and what else the reader cannot tell you.

Discovery prefers the pane’s @agent_transcript stamp (the path the agent’s own hook payload named) and falls back to walking the store’s layout from @agent_session. A pane detected from the screen alone, with no session id, has neither, and is refused.

OpenCode takes neither path: one SQLite database ($XDG_DATA_HOME/opencode/opencode.db, or ~/.local/share/opencode/opencode.db) holds every session, so the ses_* id in @agent_session is the whole of the lookup and a pane without one is refused. The database is opened read-only and kept open, never written and never checkpointed, and reading it needs no sqlite3 on your PATH: tma links SQLite rather than driving the command. A build of the crate without its opencode feature refuses those panes with unsupported-store instead; the released binary has it on.

Paging

The window is end-anchored: --last counts back from the newest event, never forward from the head. Take the older cursor a page reports and pass it back as --before for the page behind it; repeat until older is null, which means the head of the file is in the page you are holding. Pages never overlap and never skip, including when a page boundary lands inside a record that produced several events.

Cursors are opaque. They encode the file’s identity and its size when the cursor was minted, so a cursor into a file that has since been compacted, truncated or replaced is refused (cursor-invalid) rather than silently reinterpreted against whatever now sits at that offset. Re-request without --before to get a fresh window. An OpenCode cursor addresses a message timestamp and an index inside it rather than a byte offset, since a database shrinks on checkpoint without losing a row; it is refused the same way when it belongs to a different database.

Three budgets bound one call: at most 1 MiB read from disk, at most 32 KiB of headers returned, and at most 256 bytes per string. When one of them bites before --last does, the page comes back short with budget_truncated set and a usable older. A 44 MiB claude session costs the same first page as a 4 KiB pi one.

--json

A schema-1 document. events is newest-first, and each event carries an opaque cursor, its kind, the store’s ts, a preview (the body’s first line), the keys that kind defines, and body (null under --headers).

{
  "schema": 1,
  "pane": "%0",
  "agent": "claude",
  "path": "/Users/you/.claude/projects/-Users-you-app/0f3c….jsonl",
  "session": { "agent": "claude", "session_id": "0f3c…", "cwd": "/Users/you/app",
               "version": "2.1.236", "model": null },
  "older": "t1.1000013.a3e191d3.e36.c0c.0",
  "budget_truncated": false,
  "unknown": 0,
  "events": [
    { "cursor": "t1.1000013.a3e191d3.e36.d20.0", "kind": "user_message",
      "ts": "2026-01-01T00:00:11.000Z", "preview": "can you check the failing test",
      "bytes": 30, "attachments": 0, "body": null }
  ]
}

The kinds are session_meta, user_message, assistant_text, thinking, tool_call, tool_result, permission_request, turn_boundary, usage, subagent_ref, compaction, attachment, bookkeeping and unknown. The last two are the pair that matters for drift: bookkeeping is a record tma knows and deliberately does not draw, unknown is one no adapter claimed, and the document’s unknown count is how many of the latter this page held. A store that grows a record type raises that count; it never fails the read.

--event <cursor> --json answers with the same envelope and a single event key carrying that one event with its body filled in.

A refusal is a document too, so a --json consumer parses one shape either way:

{"schema":1,"pane":"%0","refusal":{"code":"store-incomplete","message":"…"}}

The codes are no-transcript, unsupported-store, store-incomplete, cursor-invalid, record-too-large and io-error.

Exit codes

0    the window (or the body) was served
3    no such pane
4    a typed refusal: no transcript, a store this reader does not serve, or a stale cursor
1    a runtime failure
2    usage error

Exit 4 is separate from exit 1 on purpose: “there is nothing to show you, and here is why” is a different fact from “something broke”.

tma watch

Persistent live dashboard for a normal pane, tmux window, or terminal of its own (not a popup): new-window "tma watch", or just tma watch in a spare terminal. It shows the picker’s rows in a live-updating list, refreshing every second and on a focus-change nudge. Enter jumps the acting client to the highlighted agent and clears its attention but keeps the dashboard open (non-modal); q, Esc, or ctrl-c quit. --temporary-session instead opens the dashboard in a dedicated tmux session and closes that session after a jump or quit; the default prefix G binding uses this mode.

a opens the action menu for the highlighted agent — the same display-menu tma act --menu renders, but aimed at the pane under the cursor rather than the one you are standing in, so a row of blocked agents is answered without jumping to each. The menu is a tmux overlay: the list keeps refreshing behind it, and nothing opens when no action is fireable on that pane.

The body adapts to the pane width. Below 76 columns it is a single list. At or above 76 it splits, with a live preview of the highlighted pane beside the list; press p to swap that preview for a full-width status table (glyph, agent, state with detail, context gauge, time-in-state, locator, title, and a model column when any visible pane stamps @agent_model), and p again to swap back. The chosen body is session-local (never persisted).

Rows are in attention order, blocked → done → working → idle → unknown, longest- in-state first within each rank. Both wide bodies then group them by repo (worktrees roll up under their origin’s name), each group under a dimmed ▸ repo-name header, groups ordered by their most urgent member so the group holding the pane that most needs you leads; every pane with no resolved repo folds into one ▸ (no repo) group. Grouping is the default; press g to flatten the list to the ungrouped attention order and g again to regroup (session-local, like p). Selection and Enter-jump target the agent under the cursor regardless of the group headers. A dimmed branch label sits beside each row (table: a branch column; single list: after the time column), present only when a visible pane resolved one. The narrow single-list body stays flat but still shows the label.

Usage: tma watch [OPTIONS]
optionmeaning
--tableOpen directly in the full-width status table when the pane is wide enough (p toggles back to the preview). A pane below 76 columns still falls back to the single list.
--temporary-sessionOpen in a dedicated one-use tmux session. Jumping or quitting exits the watcher and destroys that session; this is the default prefix G mode.
selector flagsShow only the agents in scope, e.g. a tma watch --repo app window per repo.

A plain tma watch still runs wherever you put it. prefix G is the managed one-use placement: it creates a temporary tmux session rather than leaving a watch window in your current session. A split-window -h -l 40 'tma watch' gives you a persistent pane beside your work, and a second terminal (or a second monitor) works just as well, since tma watch reaches the server over the socket like any other client. Every instance advertises its pid in @tma_watch_pid on its own pane, which is what the focus-change nudge signals; several at once are fine.

A scoped watcher still runs the unscoped poll cycle every second, so it remains a full ambient producer for every pane on the server. Its first frame is painted from stamps, which carry no repo label yet, so a --repo/--branch watcher starts empty and fills in on the first refresh.

The invoking client comes from the global --client.

tma daemon

Run the event-hub daemon in the foreground. The daemon is strictly additive (tier 3): never required.

Usage: tma daemon [OPTIONS]
optionmeaning
--ensureSpawn a detached daemon if none is running for this server, then exit 0 (idempotent).
--restartStop the daemon running for this server and start one from THIS binary, waiting until it answers. Starts one if none was running. Cannot be combined with --ensure.
--stopStop the daemon for this server and leave it stopped. Detection falls back to the poll tier, which is strictly additive. Exit 0 when nothing was running. Mutually exclusive with --ensure and --restart.

A resident daemon keeps the detection code it started with, so --restart is how an upgraded tma takes effect on demand (reload re-reads config and manifests, not the binary). It is unconditional in both directions: run it from the older binary to go deliberately back. [daemon] restart_on_upgrade, on by default, does the same automatically before every surface and every tma event, but only ever from a strictly newer build and only when a daemon is already running.

The daemon is stopped with SIGTERM and never escalated to SIGKILL: it reaps its tmux -C control clients only on a clean exit, so a killed daemon would leave one behind per monitored session. A daemon that will not take SIGTERM is reported rather than killed, and --restart exits nonzero without starting a replacement. That report is not “nothing changed”: the SIGTERM has been delivered and stands, so the daemon exits as soon as it unwedges. Start one again with --ensure once it has gone: with the default autostart = false nothing else will, since restart_on_upgrade only ever replaces a daemon and never starts one.

--restart also exits nonzero when the replacement was spawned but never answered on the socket, which means it failed to come up — the usual cause is something occupying the socket path. The bind happens before the daemon’s control-mode probe, so a slow start is not mistaken for a failed one.

tma reload

Signal the running daemon to hot-reload its config and manifests (SIGHUP). It prints a no-op message if none is running for this server; one-shot surfaces and the picker reload on their own.

A reload is all-or-nothing: a config or manifest that does not parse leaves both the running pair in place. Every surface that reloads names the failing file on stderr, once per breakage rather than once per poll tick, so a mid-edit save is quiet but a file left broken is not. A TUI (tma watch, the picker) holds its line until the surface closes, so it cannot land on the alternate screen.

Usage: tma reload [OPTIONS]

Global options only.

tma init

First-run setup. It runs the commands below in order rather than reimplementing them, so every write still shows you its diff first and re-running changes nothing:

  1. Detect. Every bundled agent install-hooks can wire is looked for on your PATH, under the names its manifest gives (the manifest name plus its process_names, minus generic ones like node, which identify a runtime and not an agent). Found, not found, and “runs under a generic name, so tma cannot detect it” are all reported.
  2. Wire each agent found, exactly as install-hooks <agent> does.
  3. Report the status line. tma never edits status-right: it is your format string, in whichever config set it. init says whether it already runs tma status, and if not prints the line to add, the config file to add it to, and the reload command.
  4. Install the keybindings, as install-keys does. An install that is already current is skipped with a note.
  5. Offer to restart a resident daemon of another build. A daemon already running keeps the detection code it started with, so the wiring just written would reach that build and not this one. Shown only when the versions actually differ, and applied only on a y (or --yes); declining is not a failure.
  6. Start the daemon with --daemon (what tma daemon --ensure does).
  7. Report with doctor, so you see the posture the steps above produced.
Usage: tma init [OPTIONS]
optionmeaning
--yesApply every step without the interactive diff confirmations (scripts, tests).
--daemonAlso start the event-hub daemon for this server.
--no-daemonWire no daemon at all: omit the server-start launcher install-keys writes by default, and start none for this server. Cannot be combined with --daemon.
--config-dir <DIR>Override the tma config dir holding the managed tmux.conf and the per-server hooks-state-<server>.toml (env TMA_CONFIG_DIR).
--conf <PATH>The tmux config to mark with the keybindings source-file line, and the file the status-line instructions name. Same default as install-keys --conf.

The per-agent config paths are not flags here; they resolve through the same TMA_* environment ladder install-hooks documents, so TMA_WRAPPER_PATH is how you move the wrapper off a read-only prefix that did not ship one (the Nix package does; see install tma).

Exit code 1 if a step failed or a confirmation was declined; the closing doctor report is informational and never changes it. With no terminal behind stdin and no --yes every confirmation declines, which init says up front.

tma install-hooks

Install, uninstall, or verify the agent and tmux hook wiring.

Usage: tma install-hooks [OPTIONS] [AGENT]

[AGENT] is the agent whose config to wire (e.g. claude); it is optional only with --check.

optionmeaning
--allAct on every agent that already carries tma wiring (the set bare --check inspects) instead of one named agent: repoints them all after an [install] wrapper_ref change or a moved binary, and with --uninstall unwires every one. It never wires an agent that was not wired before, so it cannot create a config for an agent you do not use. Naming an agent as well is a usage error.
--uninstallRemove tma’s hook wiring (symmetric to install). Removes every entry of tma’s for the wired events, including one left by an older install at a different wrapper path.
--checkVerify hook wiring and report drift. Bare (--check) inspects every known agent; with an agent named, the drift report and exit code scope to that agent. The shared wrapper and tmux server hooks are always checked.
--statuslineAlso wire the statusline context shim (Claude, Cursor), which composes tma’s context intake into the agent’s own statusLine command. Opt-in: it edits a command you own. Recorded in statusline-state.toml, so later flagless runs keep it current. With --check, require it.
--no-statuslineRemove the shim, restoring the command it wrapped, and clear the record. With --check, require its absence.
--yesApply without the interactive diff confirmation (scripts, tests).
--settings <PATH>Override the agent settings path (env TMA_CLAUDE_SETTINGS).
--gemini-settings <PATH>Override Gemini’s settings.json path (env TMA_GEMINI_SETTINGS). Defaults to ~/.gemini/settings.json.
--config-dir <DIR>Override the tma config dir holding the per-server hooks-state-<server>.toml (env TMA_CONFIG_DIR).
--wrapper-path <PATH>Override where the tma-hook wrapper is written (env TMA_WRAPPER_PATH).
--wrapper-ref <HOW>How the agent configs name the wrapper: absolute (default) writes its full path, bare writes tma-hook for the agent to resolve off $PATH, which keeps one config working on every machine. Overrides [install] wrapper_ref for this run; with bare, install refuses when the name is not findable. See Configuration.
--opencode-plugin <PATH>Override where the OpenCode plugin is written (env TMA_OPENCODE_PLUGIN).
--codex-config <PATH>Override Codex’s config.toml path (env TMA_CODEX_CONFIG). Defaults to $CODEX_HOME/config.toml, else ~/.codex/config.toml.
--codex-hooks <PATH>Override Codex’s hooks.json path (env TMA_CODEX_HOOKS). Defaults to $CODEX_HOME/hooks.json, else ~/.codex/hooks.json.
--cursor-hooks <PATH>Override Cursor’s hooks.json path (env TMA_CURSOR_HOOKS). Defaults to ~/.cursor/hooks.json.
--cursor-cli-config <PATH>Override Cursor’s cli-config.json path, which holds the statusLine context shim (env TMA_CURSOR_CLI_CONFIG). Defaults to ~/.cursor/cli-config.json.
--pi-extension <PATH>Override pi’s extension file path (env TMA_PI_EXTENSION). Defaults to $PI_CODING_AGENT_DIR/extensions/tma.js, else ~/.pi/agent/extensions/tma.js.

A completed install ends by checking the daemon: the hooks now point at this binary, but a daemon already running for this server still carries the build it started with, which is the build those hooks would reach. When the versions differ, install offers to restart it, on the same confirm-before-changing terms as every config write above (--yes accepts). Declining changes nothing and is not a failure — tma daemon --restart is there when you are ready. --check and --uninstall never make the offer.

Per-agent trust and wiring caveats (codex /hooks trust, gemini folder trust) are in Agent coverage.

tma install-keys

Install, uninstall, or verify tma’s tmux keybindings. The bindings are written to a managed file (~/.config/tma/tmux.conf, honoring XDG_CONFIG_HOME), and your tmux config is given a single source-file ... # tma keys line. By default that line is source-file -q "$XDG_CONFIG_HOME/tma/tmux.conf" "$HOME/.config/tma/tmux.conf", which tmux expands when it loads the config, so the same tmux config works on another machine (-q skips the XDG path quietly when the variable is unset). Pinning the dir with --config-dir or TMA_CONFIG_DIR writes that literal path instead, double-quoted so a space in it still parses. Install is idempotent and diff-before-write; uninstall removes the managed file and that one marked line, and touches no other binding. Uninstall exits non-zero if it cannot remove the source-file line (a declined confirmation or an unwritable config), naming the line you are left to remove by hand.

Usage: tma install-keys [OPTIONS]
optionmeaning
--uninstallRemove the managed file and the marked source-file line (symmetric to install).
--checkVerify the managed file is current and the resolved tmux config sources it exactly once; report drift. A file with or without the mouse group counts as current; a file without the daemon launcher is drift unless --no-daemon says so.
--mouseAlso write the root-table bindings that make the status-line counts clickable. With --check, require them instead of accepting either file.
--no-daemonOmit the run-shell line that starts the event-hub daemon for every tmux server that loads the file (written by default). With --check, stop requiring it.
--yesApply without the interactive diff confirmation (scripts, tests).
--conf <PATH>The tmux config to mark with the source-file line. Defaults to the first tmux config that exists, in tmux’s own load order: ~/.tmux.conf, $XDG_CONFIG_HOME/tmux/tmux.conf, ~/.config/tmux/tmux.conf. With none of them present, tma creates $XDG_CONFIG_HOME/tmux/tmux.conf (or ~/.config/tmux/tmux.conf when ~/.config exists, else ~/.tmux.conf); it only ever creates a config when you have none, so the new file cannot shadow one.
--config-dir <DIR>Override the tma config dir holding the managed tmux.conf (env TMA_CONFIG_DIR). Defaults to ~/.config/tma.

The default bindings are prefix-key bindings: a opens the picker in a popup, G opens tma watch --temporary-session --table in a dedicated one-use session (the full-width status table; g is taken by jump --blocked), A opens tma act --menu on the active pane, and j/g/b/h run tma jump with --attention/--blocked/--back/--home. The status-line driver #(tma status) is not written; add it to status-right yourself. See Install the keybindings and the full key tables.

--mouse adds four root-table bindings that dispatch on #{mouse_status_range}. A left-click walks a three-arm chain, first match wins: the blocked count jumps to the longest-blocked agent, any other tma:* range opens the picker popup, and anything else falls through to tmux’s own switch-client -t=. A right-click on any tma range opens tma jump --menu. They need set -g mouse on, which tma never sets (it changes copy/paste in every pane), and they claim tmux’s status-line mouse keys: a left-click elsewhere still switches window, a right-click on a window name no longer opens tmux’s window menu (Alt-right-click still does). tma doctor warns when the bindings are installed but mouse is off. Full write-up in Clickable status segments.

Every install ends with one more line, which --no-daemon omits:

run-shell -b 'tma --socket-path "#{socket_path}" daemon --ensure >/dev/null 2>&1'

The managed file is sourced when a tmux server loads its config, so this fires once per server start and your servers run at tier 3 without being asked. run-shell expands #{socket_path} to the socket of the server doing the loading, so tmux -L work starts a daemon for itself rather than for the default server a bare tma daemon --ensure would resolve. Nothing accumulates on a re-source: --ensure takes a single-instance lock and exits 0 when a daemon already holds it. The daemon exits on its own when its tmux server does, so there is no matching stop line.

--no-daemon is a standing choice, not a one-off: a plain --check reports the missing line as drift, so pair it (--check --no-daemon) in whatever script verifies your setup. The daemon can still be started by hand with tma daemon --ensure or lazily with [daemon] autostart = true; see Run the daemon.

tma doctor

Diagnose each agent pane’s effective tier (3 daemon, 2 hooks, 1 polling) and why: hooks wired, daemon alive, last evidence source and age, and the ambient-driver check. Read-only.

Usage: tma doctor [OPTIONS]
optionmeaning
--jsonEmit JSON ("schema": 1) instead of the human-readable report.
--exit-codeExit 1 when the report carries a warning or a pane is below the tier its manifest supports. Without it doctor is a report: exit 0 unless the config fails to load or the server is unreachable.

Beyond the per-pane tier, doctor reports the conditions that quietly disable a tier:

checkwhat it means
tmux versionThe server’s own #{version} against the 3.6 floor tma is tested on. Older servers load configs in a different order and expand display-popup differently, so a keybinding or the picker can misbehave for reasons nothing else in the report explains. A warning line only: it never counts toward --exit-code, and a version string tma cannot parse produces no warning at all.
attached clientsA #() status job only runs while a client draws the status line, so a server with none has no ambient polling floor. Reported as a warning only when no daemon is covering for it.
global statusWith status off, the #(tma status) driver never runs and display-message notifications are invisible.
clickable segmentsThe install-keys --mouse bindings are installed but the server’s mouse option is off, so no click can reach them.
tmux hooksPer hook: present, stale (it runs a different command than this build installs, e.g. a moved binary), wiped (recorded but gone server-wide — a restart), or missing.
process_names truncationA manifest entry longer than the 15 characters both macOS libproc and the Linux kernel truncate comm to, with no truncated spelling beside it, can never match a pane.
hook demotionA pane that registered through a hook (@agent_session stamped) whose current evidence came from capture: output kept arriving that its hooks did not account for. A working hook claim accounts for output until capture contradicts it, so a long tool call does not demote a healthy pane.
manifests and actionsFiles the loader skipped, and actions naming an unknown agent.
remote panesA pane whose foreground is a remote shell (ssh, mosh, docker, podman, kubectl). Neither the process walk nor a capture crosses that boundary, so an agent behind it reports only if its hooks can reach this tmux socket (Run an agent in a container). Any @agent_* options such a pane still carries are held, not refreshed. Reported, not warned about: running an agent elsewhere is a choice, not a misconfiguration.
unreadable stampsA pane carrying an @agent_* option that does not decode. Every read path treats a corrupt stamp as no stamp, so the pane reads as never-stamped with nothing else to say why; doctor names the option and the value. tma debug explain prints the same fact for one pane.
stamps tma did not writeAn @agent_state outside the closed token set, or one set with no @agent_stamped_at beside it, which no tma write produces. Both say a second tool is writing the pane’s state; doctor names the value and points at the @agent_state contract. Reported on the same line as an unreadable stamp, and counted the same way.

Reading the report section by section, and the --exit-code CI recipe, are in Diagnose with tma doctor.

tma completions

Write the completion script for one shell to stdout.

Usage: tma completions <SHELL>

<SHELL> is one of bash, zsh, fish, elvish, powershell.

The script is generated from tma’s own argument tree, so it covers every subcommand, every flag, and every fixed value set — --state, --until, --wrapper-ref, --format. It completes no runtime value: --agent, --session, --repo, --branch, and an action name for tma act are not offered, because a static script cannot know what your tmux server or your config holds. The internal verbs (event, clear-attention, supervise) and the internal daemon flags are left out.

Where each shell reads the file from, for a per-user install:

shellpath
bash~/.local/share/bash-completion/completions/tma
zsh_tma, in a directory on your $fpath (e.g. ~/.local/share/zsh/site-functions/_tma)
fish~/.config/fish/completions/tma.fish
elvishanywhere, sourced from ~/.config/elvish/rc.elv

~/.local/share/zsh/site-functions is not on zsh’s default $fpath. Add it above compinit:

fpath=(~/.local/share/zsh/site-functions $fpath)

Or skip the file and evaluate the script at shell startup, at the cost of one tma run per new shell:

eval "$(tma completions zsh)"     # bash and zsh
tma completions fish | source     # fish

You may not need any of this. A release tarball ships the four Unix scripts in completions/, scripts/install.sh places them for the shells it finds (set TMA_NO_COMPLETIONS=1 to skip), and the nix package installs them itself.

tma debug

Manifest-authoring and inspection tools.

Usage: tma debug [OPTIONS] <COMMAND>
subcommandsummary
redactRedact a capture (paths, emails, and --pattern regexes) to stdout, preserving layout width, so it can be committed as a fixture.
capturePrint exactly what the detector saw for a pane, in fixture format.
explainRun identity, the rule engine, and fold for a pane; print evidence, matched and failed rules, and the verdict. --json emits the versioned schema.
transitionsPrint the running daemon’s recent state transitions (its in-memory ring). --json emits the versioned schema.
notify-testFire the notify command a trigger resolves to against a representative payload. --trigger blocked|done|context_high|stall (default blocked).
stampInternal, unstable: apply a guarded stamp to a pane, for testing the pane-option write guards directly. Not a public interface.

tma debug transitions

Reads the daemon’s bounded ring of recent state transitions over its socket:

$ tma debug transitions
transitions (3 held, cap 256, 12 recorded over the daemon's life):
  %1     -        -> working  at=1700000000000 src=hook
  %1     working  -> blocked  at=1700000001500 src=hook

Oldest first, - for a pane’s first observation. --json emits {"schema":1,"cap":...,"recorded":...,"transitions":[...]} with from as an explicit null.

The ring is daemon memory: it needs a running daemon (the command says so and exits non-zero otherwise) and starts empty after a restart. A daemon older than this build rejects the request and the command says to restart it — a reload cannot add a protocol verb. For a durable per-notification record, use [notify] log.

tma debug notify-test

A real notification is fire-and-forget with the command’s output discarded, which makes a broken hook silent. This subcommand runs the same command the same way, except that it waits, shows stderr, and reports the exit status:

$ tma debug notify-test --trigger blocked
payload   {"schema":1,"agent":"claude","pane":"%0","state":"blocked",...}
command   ~/.local/bin/tma-notify
exit      0

It needs no tmux server and no agent: the payload is synthesized (with repo and branch resolved from the current directory) so a hook sees the real shape. It exits non-zero when the trigger resolves to no command or the command failed, so it works as a check. The outcome updates the same record tma doctor reads, so a passing run clears a stale failure report.

tma version

Print version and build information (tma <version>).

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.

keytmux commanddoes
adisplay-popup -E -w 80% -h 60% 'tma'Open the picker in a popup.
Grun-shell 'tma watch --temporary-session --table --client "#{client_name}"'Open the full-width table in a dedicated temporary session.
jrun-shell 'tma jump --attention --client "#{client_name}"'Jump to whoever wants you: blocked first, then finished-unreviewed.
grun-shell 'tma jump --blocked --client "#{client_name}"'Jump to the longest-blocked agent.
brun-shell 'tma jump --back --client "#{client_name}"'Return one step along the jump trail.
hrun-shell 'tma jump --home --client "#{client_name}"'Return to the trail’s oldest origin.
Arun-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.

clickdoes
left-click the blocked counttma jump --blocked: go to the longest-blocked agent.
left-click any other tma countOpen the picker popup, the same one prefix a opens.
right-click any tma segmenttma jump --menu: a tmux menu of every agent.
left-click outside a tma segmenttmux’s own switch-client -t=, so clicking a window name still switches to it.
right-click outside a tma segmentNothing. 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.

keydoes
enterJump to the highlighted agent, clear its attention flag, close the picker.
tabOpen the tmux action menu for the highlighted agent.
ctrl-sToggle the scope between every session and the invoking one.
/ Move the selection (wraps at both ends).
backspaceDelete the last query character.
any printable characterAppend to the fuzzy query.
esc, ctrl-cClose.

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

gesturedoes
move the pointer over a rowUnderline it: what a click would take.
click a rowSelect it, the same as moving the highlight there with /.
click the selected row againJump to it and close, the same as enter. Two clicks, so a stray one cannot move your client.
wheel up / downMove 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

keydoes
enterJump to the highlighted agent and clear its attention flag. A plain watcher stays open; the prefix G temporary watcher exits.
a, tabOpen the tmux action menu for the highlighted agent. (tab is the picker’s spelling; both work here.)
pSwap the live preview for the full-width status table, and back. Wide body only.
gFlatten the repo grouping, and regroup. Wide body only.
k / j, / Move the selection (wraps at both ends).
q, esc, ctrl-cQuit.

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.

keydoes
1-9Fire the nth entry. The tenth and later carry no digit.
/ , enterMove and fire.
q, escDismiss.

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.

flagkeydoes
--attentionprefix jThe next agent that wants you: blocked first, then finished-unreviewed.
--blockedprefix g, left-click the blocked countThe longest-blocked agent.
--nextnoneThe next agent after the current pane, in session then window then pane order. This is the default when no direction flag is given.
--backprefix bOne step back along the return trail.
--homeprefix hThe trail’s oldest origin, clearing the trail.
--pane <ID>noneA named pane. What a menu entry and the picker’s Enter both run.
--menuright-click any tma segmentA 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:

  1. --config <path>
  2. the TMA_CONFIG environment variable
  3. $XDG_CONFIG_HOME/tma/config.toml
  4. ~/.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.

keydefaultmeaning
dwell_secs3Anti-flicker dwell before a working-to-idle drop is published.
hook_decay_secs60How long a working or idle hook claim outweighs later screen evidence.
blocked_decay_secs300The 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_secs3Stamp-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.

  • glyph is the character rendered for that class.
  • color is 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

keydefaultmeaning
from_eventfalseOpt in to daemonless direct-fire from tma event.
commandunsetOptional 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.
bellfalseAlso ring the firing pane’s terminal bell.
oscfalseAlso 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_777falseAlso 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_progressfalseEmit 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.
logunsetPath 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_titlefalseSend 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.
blockedunsetA sub-table routing the blocked trigger: { command = "..." }.
doneunsetThe same for the done trigger.
context_highunsetA 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.
stallunsetA 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

keydefaultmeaning
logunsetPath 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

keydefaultmeaning
reconcile_interval_ms2000How 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_connections4How 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

keydefaultmeaning
eventsfalseSet true to also install a pane-focus-in hook. Requires tmux focus-events on.

[install]: how agent configs name the wrapper

keydefaultmeaning
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

keydefaultmeaning
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

keydefaultmeaning
bintmuxThe 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.

keydefaultmeaning
sweep_secs45Reconciliation-sweep cadence.
quiet_ms1000Per-pane active-to-quiet capture trigger, in milliseconds.
zero_member_recheck_secs1Clientless-session liveness recheck cadence.
demote_edges5Hook-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.
autostartfalseAuto-start the daemon on first use of a surface (ls/status/jump/picker/watch/wait/subscribe).
restart_on_upgradetrueReplace 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_namesunsetA 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}"
tokenexpands 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 --restart is 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).

keydefaultmeaning
name(required)The agent name this override applies to.
enabledtrueSet 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
keydefaultmeaning
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:

tokenstatemeaning
permissionblockeda tool-use permission prompt: approving grants the one action in front of the user
planblockeda plan-approval dialog. Its affirmative option grants every following action, so tma act approve deliberately does not resolve here
trustblockeda workspace-trust gate. Its affirmative option grants the whole folder, so neither approve nor deny resolves here
questionblockedthe 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_limitworking or blockeda 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.

optionscopesemantics
@agent_name, @agent_pidpaneidentity (pid: process-group leader found by the walk)
@agent_state, @agent_detailpanethe 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_sourcepaneprovenance 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_atpaneepoch ms of the evidence behind the current state
@agent_sincepaneepoch 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_atpaneper-pane freshness marker, written last in a chained stamp
@agent_hashpanescheduling 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_attentionpanepresentation 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_atpanenotification-episode marker, written only by the notifier
@agent_turn_atpaneepoch 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_sessionpaneowning agent session id from hook registration; the subagent guard compares incoming event session ids against this
@agent_transcriptpanepath 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_subagentspanespace-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_pctpanecontext-utilization metric percent (integer 0100), 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_atpaneepoch 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_tokenspanetokens 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_atpaneepoch 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_atpanethe 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_atpanethe 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_pctpaneaccount rate-limit utilization percent (integer 0100) 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_windowpanewhich 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_atpaneepoch 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_atpaneepoch 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_usdpanethe 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_modelpanebest-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_requestpanethe 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_requestpanethe 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_callpanethe 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_summarypanea 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_endpointpanethe 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_ignorepaneyou 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_untilpanenotification 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_actionpanesingle-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_repeatpaneconsecutive-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_summarywindowrollup, 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_summarysessionthe 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_pollserverhint only; the per-pane @agent_stamped_at is authoritative for freshness
@tma_watch_pidpane (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_origwindowthe 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_origwindowthe 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_lastwindowthe 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:

keytypemeaning
panestringtmux pane id (e.g. %5)
agentstringagent name
statestringidle / working / blocked / unknown
detailstring or nulldetail token, null when none. Open vocabulary — read defensively
sincenumberepoch ms of the last state transition (original unsuffixed key, kept for compatibility)
since_msnumberthe same value as since; names the unit, preferred in new consumers
episode_msnumberepoch 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)
locatorstringsession:window.pane
titlestringpane title
attentionbooleantrue when the pane still carries @agent_attention
donebooleantrue when the pane is idle and carries @agent_attention: finished with output nobody has reviewed
sessionstring or nullowning agent session id, null when the pane never registered one
transcriptstring or nullpath to the agent’s transcript file (@agent_transcript), null when no hook payload ever named one
permission_requeststring or nullthe 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_msnumber or nullepoch 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
contextnumber or nullcontext-utilization percent (0100), null when the agent has no telemetry coverage or the channel reported no window
context_at_msnumber or nullepoch ms of the evidence behind context, null when context is
mutedbooleantrue 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
tokensnumber or nulltokens 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
quotaobject or nullthe 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 0100 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_usdnumber or nullthe 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
repostring or nullthe 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
branchstring or nullthe pane’s checked-out branch (the literal HEAD for a detached head), null when repo is
worktreeboolean or nullfalse for a resolved main checkout, true for a linked worktree, null exactly when repo is
pending_toolstring or nullthe tool name of the permission decision the pane is waiting on (@agent_pending_tool), null when nothing is pending
pending_callstring or nullthat call’s id, null exactly when pending_tool is (an agent whose hook carries no id stamps "")
pending_summarystring or nullone 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
serverstringthe tmux server this row was observed on: its own #{socket_path} (e.g. /private/tmp/tmux-501/default)
hoststringthe 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:

keytypemeaning
schemanumberpayload schema version (2); kept the first key so a reader sees it up front
agentstringagent name
panestringtmux pane id
statestringthe landed state (blocked, or idle for a completion)
detailstring or nulldetail token, null when none. Open vocabulary — read defensively
sessionstring or nullowning agent session id, null when none
locatorstringsession:window.pane
titlestringpane title. Absent unless [notify] include_title = true — see below
repostringrepo name resolved from the pane’s working directory, "" when it is not a checkout
branchstringbranch name (the literal HEAD when detached), "" when unresolved
since_msnumberage 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_msnumberepoch 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_pctnumber or nullthe 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.

keytypemeaning
schemanumberpayload schema version (1)
actionstringthe action name
panestringthe target pane id
outcomestringthe closed outcome token (below)
exit_codenumberthe process exit code this outcome maps to; for an exited outcome it is the exec child’s own code
reasonstring or nullthe refusal reason token when outcome is refused, which target went away when outcome is vanished, null otherwise
cachedbooleanpresent 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.

keytypemeaning
namestringthe action name (also its file stem)
labelstringthe human label
kindstringkeys, text, or exec
agentsarray of stringthe 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
whenobject or nullthe gate, or null when the action is always fireable for its agents
fireablebooleanpresent only with --pane: whether the action can fire on that pane right now
reasonstring or nullpresent 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:

keytypemeaning
schemanumberpayload schema version (1)
actionstringthe action name
panestringthe target pane id
agentstringthe agent name
outcomestringthe outcome token (exited / timeout / error)
exit_codenumber or nullthe child’s exit code for exited, null for a deadline kill or spawn failure
locatorstring or nullsession:window.pane, null when the pane is already gone
lock_release_failedbooleanpresent 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:

keytypemeaning
schemanumberpayload schema version (1)
daemonobjectalive (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_driverobjectpolling (boolean: the server option @tma_last_poll carries a non-zero timestamp), last_poll_age_ms (number, null when it does not)
clientsobjectattached (number of attached clients)
status_optionobjectenabled (boolean: the server’s global status)
mouseobjectbindings_installed (boolean), enabled (boolean: the server’s mouse option). Both true is the working state; installed-without-mouse is the warning
watchobjectrunning (boolean), watchers (number of panes advertising @tma_watch_pid)
wrapperobjectpath (string), present (boolean) for the tma-hook wrapper
notifyobjectlast_failure: null, or an object of at (epoch ms), reason, command
tmux_hooksarrayone object per checked hook: hook (name), present (boolean), hook_state (present / drifted / wiped / missing)
manifestsobjectok (number loaded) and issues, an array of { file, problem }
process_name_issuesarray{ agent, name, comm_max } per process_names entry past the truncation width
process_walkobjectok (boolean: the ps walk ran) and error (string or null). With ok: false the agents array holds only panes a hook registered
nested_multiplexersarray{ pane, locator, command } per pane running an inner multiplexer client
remote_panesarray{ pane, locator, command, stamped } per pane behind a remote shell; stamped says whether it still carries a held @agent_* stamp
ignored_panesarray{ pane, locator, value } per pane carrying @agent_ignore, with the value you set
stamp_issuesarray{ 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)
agentsarrayone object per agent pane (below)
actionsobjectok (number loaded) and issues, an array of { file, problem }

Each agents element carries this exact key set:

keytypemeaning
pane, agent, locatorstringthe pane, its agent name, and session:window.pane
statestring or nullthe stamped state token, null when the pane has none
sourcestring or nullprovenance of that state (hook / capture / process; activity is legacy, read-only)
evidence_age_msnumber or nullage of the evidence behind it
hook_statusstringwired, 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_wiredbooleantrue only for wired, so a consumer needs no token table for the common question
modelstring or nullthe best-effort @agent_model label
window_coveredboolean or nullwhether [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_okboolean or nullwhether the pane’s API endpoint answered; null when the agent has no API lane
hook_demotedbooleanregistered 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
tiernumberthe effective tier (3 / 2 / 1)
tier_reasonstring or nullwhy 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.

keytypemeaning
schemanumberpayload schema version (1)
pane, locator, command, titlestringthe pane id, session:window.pane, #{pane_current_command}, and the pane title
agentstringthe resolved agent name, or unknown
identity_sourcestring or nullobserved (the process walk found it) or registered (a hook claimed the pane); null when nothing identified it
out_of_scopestring or nullthe foreground command that put the pane out of scope (a remote shell, an inner multiplexer)
out_of_scope_kindstring or nullwhich category that command falls under
registered_behindstring or nullthe boundary a live registration outranks: the pane is in scope, but its agent runs where no capture reaches
registered_behind_kindstring or nullthat boundary’s category
ignoredbooleanthe pane carries @agent_ignore, which is why an otherwise recognizable pane reports no agent and no verdict
foreground_is_agentbooleanwhether the foreground command is the agent itself; false caps every screen verdict at unknown
scrolled, history_viewbooleanthe pane is scrolled back, and the screen is showing history rather than live state
evidencearray{ source, claim, at, meta } per evidence record the fold saw
rulesarray{ index, matched, state, detail, priority, region, skip_state_update } per screen rule evaluated; empty when no rules ran
verdictobject or nullthe 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.

tokenmeaning
idleprompt shown, nothing running
workingthe agent is processing; the ball is with the agent
blockedwaiting on a human; the ball is with the person
unknowna 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:

tokenwith statemeaning
permissionblockeda tool-use permission prompt; approving grants the one action in front of the user
planblockeda plan-approval dialog, whose affirmative option grants every following action
trustblockeda workspace-trust gate, whose affirmative option grants the whole folder
rate_limitworking or blockeda 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:

tokenmeaning
hookthe agent itself reported this, through an event, with no inference
captureit was read off the pane’s screen
processit came from the process walk
activitylegacy, 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 working or idle hook 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:

optionscopegrammar
@agent_summarywindow<state>:<count> pairs, space separated, in the fixed order blocked working idle unknown, zero counts omitted (blocked:1 working:2)
@agent_session_summarysessionthe 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, blocked or unknown into @agent_state, and unknown rather than a fifth token when nothing fits;
  • write @agent_stamped_at on every write, in epoch milliseconds, last in the chain;
  • write @agent_source from its closed set, hook for the agent’s own event and capture for a screen read, and put its own identity in @agent_name instead;
  • unset the state options when its agent exits.

SHOULD:

  • set @agent_evidence_at to the instant of the evidence rather than of the write, since that is what arbitration compares;
  • set @agent_name, and @agent_session when 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_since only on a state change and hold it otherwise;
  • keep @agent_detail to [a-z0-9_-].

MUST NOT:

  • write @agent_summary or @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

agentmechanismhook coveragecontext telemetrynotes
Claude Codesettings.json hooks block or plugin manifestworking / idle / blocked / lifecyclestatusline 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 CLItwo 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-carriedrollout 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 tabletma 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.
OpenCodeJS plugin in ~/.config/opencode/plugin/working / idle / blocked / lifecycle (registration only); blocked, working and idle also screen-carriedNo 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 CLIsettings.json hooks object, native event namesworking / 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 CLIuser-level ~/.cursor/hooks.json in cursor’s own shape, plus a statusLine shim in ~/.cursor/cli-config.jsonworking / idle / lifecycle via hooks; blocked screen-onlystatusLine 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 gaugeCursor 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.
piextension module in ~/.pi/agent/extensions/working / idle / lifecycle via the extension; no blockedgetContextUsage() 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_tokenswhy
piyes — context_usage.tokensthe number pi divides by contextWindow to get the percent it sends; a footprint by construction
Cursor CLIyes — context_window.total_input_tokensthe 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 Codeyes, from 2.1.132 — context_window.total_input_tokensthe 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 CLInototal_token_usage mixes the two meanings (below)
Gemini CLI, OpenCodenono 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

optakesendpointoutcome on 2xx
permission-replyreply = once / always / rejectPOST {base}/permission/{id}/reply, {"reply":"<verdict>"}replied
question-replythe caller’s picked labelsPOST {base}/question/{id}/reply, {"answers":[["<label>"]]}replied
question-rejectnothingPOST {base}/question/{id}/reject, no bodyreplied
interruptnothingPOST {base}/session/{id}/abort, no bodysent

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:

  • eventpermission.asked, properties = { id, sessionID, permission, … }; the id is the reply’s requestID. The plugin forwards it as request_id (accepting requestID too), stamped to @agent_permission_request under the session-ownership filter and cleared on the working/idle edge or a permission.replied event.
  • endpointPOST {serverUrl}/permission/{requestID}/reply with body {"reply": "once" | "always" | "reject"}, a 2xx on success and 404 once the prompt is answered or withdrawn. The plugin stamps {serverUrl} (from its PluginInput.serverUrl) to @agent_api_endpoint at registration; the server pins its own port, so there is no hardcoded default. [api.opencode] api_base in config.toml is 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 under esc 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.
  • idle is deliberately absent from [capture].visible for all but claude. visible is 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).

actionclaudecodexcursorgeminiopencodepi
approve1yy1API oncen/a
denyEscapeEscapen3API rejectn/a
interruptEscapeEscapeC-cEscapeEscapeEscape
compact/compact Entern/an/an/an/an/a
steertext + Entertext + Entern/an/atext + Entern/a
steer_nowtext + Entertext + Entern/an/an/an/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

hooktma eventstate effect
SessionStartagent-startpane registered, state idle
UserPromptSubmitworkingworking
PreToolUse / PostToolUseworking (heartbeat)working, refreshes liveness
PermissionRequestblockedblocked / permission, the moment the decision is needed
Notification (permission / idle-prompt)blockedblocked / permission (fallback)
Notification (usage-limit auto-continue)rate limitworking / rate_limit while it resumes itself, blocked / rate_limit when it halts
Stopidleidle
SubagentStart / SubagentStopsubagent bookkeepingappend/remove session id in @agent_subagents; never a top-level state change
SessionEndagent-endpane 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_typeclaimwhy
quota_auto_resume_firedworking / rate_limitClaude 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_staleblocked / rate_limitthe 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_disabledblocked / rate_limitthe 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 eventtma eventstate effect
plugin load / session.createdsession-startpane registered, state idle
session.status = busy / chat.message / tool.execute.beforeuser-prompt-submitworking
session.idle / session.status = idlestopidle
permission.askedpermission-requiredblocked, detail permission
question.askedquestion-requiredblocked, 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 pending permission.asked is answered. It clears blocked, but it is redundant with tokens the plugin already forwards on the same edge: an approve (once/always) is immediately followed by tool.execute.before (⇒ working) and a reject/turn-end by session.idle (⇒ idle). Since the plugin must be live to receive permission.replied at 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 the once (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 typetma eventstate effect
agent-turn-completenotify (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 eventtma eventstate effect
SessionStartagent-startpane registered, state idle
UserPromptSubmitworkingworking (fires pre-response, lands even on a failed turn)
PreToolUse / PostToolUseworkingworking
PermissionRequestblockedblocked/permission (payload names the pending tool)
Stopidleidle
SessionEndagent-endpane deregistered, options removed
SubagentStart / SubagentStopsubagent bookkeepingappend/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 eventtma eventstate effect
SessionStart (source = “startup”)agent-startpane registered, state idle
BeforeAgent (prompt)workingworking
BeforeTool / AfterTool (tool_name/tool_response)workingworking
AfterAgent (prompt_response/stop_hook_active)idleidle (fires last in a turn)
Notification (notification_type = “ToolPermission”)blockedblocked/permission
SessionEnd (reason = “exit”)agent-endpane deregistered, options removed
SubagentStart / SubagentStopsubagent bookkeepingwired 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 eventtma eventstate effect
sessionStart (model, is_background_agent)agent-startpane registered, state idle
beforeSubmitPrompt (prompt)workingworking (interactive only; headless takes the prompt from argv)
preToolUse / postToolUse (tool_name/tool_output)workingworking
postToolUseFailure (failure_type/error_message/is_interrupt)workingworking (matcher "is_interrupt":false)
stop (token counts, status)idleidle
sessionEnd (reason/final_status = “completed”)agent-endpane deregistered, options removed
subagentStart / subagentStopnot observedabsent (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 eventtma eventstate effect
session_start (reason=“startup”)agent-startpane registered, state idle
before_agent_start (prompt, systemPrompt, …)workingworking
tool_execution_start (toolName, args)workingworking
agent_settled (type only)idleidle (fires once per turn)
session_shutdown (reason=“quit”)agent-endpane deregistered, options removed
SubagentStart / SubagentStopsubagent bookkeepingwired 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

fieldrequiredtypemeaning
min_engine_versionyesversion stringThe 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]yestableHow to recognize the pane.
[hooks]notablePresent marks the agent hook-capable. Absent is the screen-only floor.
[capture]yestableWhich states the screen rules reliably detect.
[[rules]]noarrayScreen rules.
[details]notableDetail-token alternate spellings.
[telemetry]notableMetric 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]

fieldrequiredtypemeaning
process_namesyesarray of string#{pane_current_command} values that cheaply flag a candidate agent pane.
title_patternsnoarray of stringRegexes 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 decides foreground_is_agent, and a false answer caps every screen verdict at unknown — 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.

fieldrequiredtypemeaning
coversnoarray of tokenWhich states and lifecycle the agent’s hooks report: any state token, plus the literal lifecycle. This is the first coverage gate.
[[hooks.map]]noarrayEvent-to-claim mappings.

[[hooks.map]]

fieldrequiredtypemeaning
eventyesstringAgent hook event name (e.g. Notification, SessionStart).
matchernostringOptional payload matcher regex (e.g. `permission_prompt
claimyestableThe claim this event raises: either a state claim { state = "...", detail = "..." } (detail optional) or a lifecycle claim { lifecycle = "start" } / { lifecycle = "end" }.
turn_endnoboolWhether 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]

fieldrequiredtypemeaning
visiblenoarray of stateThe 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.

fieldrequiredtypemeaning
stateyesstateThe state this rule asserts on match.
detailnodetail tokenDetail to attach (e.g. permission for a permission prompt).
prioritynointegerHigher wins on multiple matches. Default 0.
regionyesstringWhere to look (see below).
matchyesmatcherThe text predicate (see below).
skip_state_updatenoboolThis screen shows history, not live state: freeze, do not restate. Default false.

region

valuemeaning
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.
visibleMatch 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.
titleMatch against the pane title.

match

A screen matcher composes leaf text predicates. TOML is externally tagged:

formmeaning
{ 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.

fieldrequiredtypemeaning
[telemetry.context]notableHow tma obtains this agent’s context-window utilization percent. Absent means the agent has no gauge.

[telemetry.context]

fieldrequiredtypemeaning
channelyestokenThe 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.
formatyesstringThe 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

fieldrequiredtypemeaning
min_engine_versionyesversion stringThe minimum engine version this action needs (e.g. "0.1"). A manifest that needs a newer engine is rejected with an upgrade error.
nameyesstringThe action name; must equal the filename stem. Invoked as tma act <name>.
labelyesstringThe human label shown in --list and the menu.
kindyeskeys | text | execkeys 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.
whennotableThe gate. Absent means the action is always fireable for its applicable agents.
agentsno (exec)array of stringWhich 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.
requiresnoarray of tokenContext keys that must be non-empty for the gate to pass: session, cwd, pid, title. An unknown token is a parse error.
confirmnoboolMark the action as wanting a second factor (below). Default false.
detachno (exec)boolRun an exec action detached under a tma-owned supervisor. Default false. Forbidden for keys.
timeout_msno (exec)integerSynchronous exec timeout in milliseconds. Default 30000.
detach_timeout_msno (exec)integerDetached exec wall-clock deadline in milliseconds, after which the supervisor kills the process group. Default 900000 (15 minutes).
commandyes (exec)stringThe exec command, passed to sh -c verbatim with no substitution. Required for exec, forbidden for keys.
[keys]keystablePer-agent key sequences. Forbidden for exec. A keys action needs at least one entry across [keys], [api] and [hook].
[api]keystablePer-agent API-channel transports (below). Forbidden for exec. An agent may appear in [keys] or [api], never both.
[text]texttablePer-agent text transports (below). The only transport table a text action may carry, and forbidden for the other two kinds.
sigilsno (text)array of stringLeading characters a text payload may not start with, one character each. Defaults to ["/", "!"]; an explicit [] opts out. text only.
[hook]keystablePer-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.

fieldrequiredtypemeaning
statenoarray of stateThe states that satisfy the gate: idle, working, blocked, unknown.
detailnoarray of detail tokenDetail tokens that satisfy the gate (e.g. permission).
context_pct_minnointegerMinimum context-utilization percent. Fails closed: an absent metric refuses.
context_pct_maxnointegerMaximum 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.

fieldrequiredtypemeaning
prefixnoarray of keyKeys sent before the string (empty by default: most composers already have focus).
suffixnoarray of keyKeys sent after it. ["Enter"] submits the line.
steer_nownoboolThe 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):

tokenrule
emptyThe string is empty or all whitespace.
too-longOver 4096 bytes. One steer is one message, not a file.
control-bytesAny 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.
sigilThe 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.

tokenenv varsource
sessionTMA_SESSION_IDthe agent’s own session id (@agent_session)
cwdTMA_CWDthe pane’s current path
pidTMA_PIDthe process-group leader pid
titleTMA_TITLEthe 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 varvalue
TMA_ARGthe first --arg value
TMA_ARG_1TMA_ARG_Nevery value in order
TMA_ARG_COUNThow 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

namekindgateeffect
approvekeysstate = ["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).
denykeysstate = ["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).
interruptkeysstate = ["working"]Interrupt a working agent (Escape everywhere but Cursor, which takes C-c).
compactkeysstate = ["idle"], context_pct_min = 75Compact the context window once it is high (/compact Enter for Claude).
steertextstate = ["idle"]Send one line of your own text to an idle agent, submitted with Enter (Claude, Codex, OpenCode).
steer_nowtextstate = ["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

tcarriesanswered with
hellothe client name, its version, and the device fingerprinthello, or error when the envelope’s schema is one this host does not implement
snapshotan optional selector (session, repo, branch, agent, state)snapshot
subscribeevents plus the same selectorack, then a stream of snapshot or edge frames
carda pane idcard
windowa pane id, how many events, a cursor to page back from, and a budgetwindow
eventa pane id and one cursorevent, with the body populated
dispatcha slot, a pane, an action, the binder, and the text or answers payloadreceipt
receiptsa slot id, a claim time, or bothreceipts

Responses

tcarries
hellothe host name, its version, the reconcile interval, and the scopes this device was granted
snapshotagents, the fleet rows in scope
edgeone pane’s state transition, as the cycle observed it
cardwhat a blocked pane is asking: permission, question, informational, or none
windowa page of transcript event headers, newest first, with an older cursor
eventone event with its body
receiptwhat one dispatch resolved to
receiptsthe ledger, filtered
ackthe request was accepted and has no body of its own
errora 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:

  1. 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 read gets a receipt reading refused / scope-denied with nothing spent and nothing sent. An exec action and an action outside the scope table are refused here too.
  2. 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.
  3. The binder says the thing you approved is still on screen. expect_episode_ms and expect_permission_request are re-checked inside the pane’s single-flight lock, against the same read the gate is re-asserted from. A pane that moved on refuses episode-changed; one that no longer carries the quoted id refuses request-gone. A zero expect_episode_ms is “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.

cardwhencarries
permissionblocked/permissionthe lane, the options, an extraction confidence, the pending call, and the binder
questionblocked/question, with the agent’s own question set fetchedthe request id and the questions verbatim
informationalevery other blocked detail, a token this build has never heard of includedthe detail and a short headline, and nothing to fire
noneanything the cycle does not call blockednothing

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-once and reject-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_request names one on disk. Its presence is the whole difference between the hook lane and the screen one: a hold that already expired left none behind, and the same pane degrades to screen / failed.
  • The approve and deny labels 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 /question against the endpoint @agent_api_endpoint resolved, bounded at 750 ms, and asked only when the pane carries a @agent_question_request id. 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_call when 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.

crateowns
tma-coreThe 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-tmuxThe 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-runtimeTier 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-daemonTier 3, and only tier 3: the serve loop and notification dispatch. Nothing below it depends on it.
tma-ui-coreThe 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-uiThe 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.
tmaThe 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_detail is an open, additive token that qualifies the state (permission, rate_limit, compacting, and so on). It can be empty. A rate-limited agent is working/rate_limit, because the agent auto-resumes and the ball is not with the human; an agent that halts asking for confirmation is blocked/permission on its own prompt evidence.

    That rate_limit split is implemented, not hypothetical. Claude Code waits out a usage limit in the open session and continues on its own, which is working/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 is blocked/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 blocked fires 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 of tma ls --json’s detail key and let the second kind wait.

  • @agent_attention is 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 question tma status and prefix-j exist 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 is idle with 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 an idleidle edge it cannot tell from a quiet idle pane. The manifest names that hook (turn_end), the intake stamps @agent_turn_at when it raises, and one turn end reported on two channels (codex sends both Stop and notify) 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 closed state vocabulary stays four tokens, and a script reading state never 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 while focus-events is on — and never for pane output or for tma’s own polling). The focus reports are worth knowing about: with focus-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 -CC this 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:

  1. Agent hooks. A cooperating agent runs a command at each lifecycle point, so it tells tma it just blocked, at the instant it blocks, with zero inference. Highest fidelity.
  2. 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.
  3. 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:

  1. a fresh hook event from a registered pane;
  2. visible blocker chrome on the live viewport;
  3. visible working chrome, which means working;
  4. visible idle chrome, which means idle;
  5. 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 tma for stamps to stay fresh. #(tma status) in status-right is that required ambient driver; without it, ambient surfaces render nothing.
  • Hook tier. tma event direct-stamps the moment a hook fires, with no daemon involved. State is event-latency, and a resident tma watch refreshes within about a fifth of a second of a focus change: the after-select-pane / session-window-changed hooks 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 send SIGUSR1, which the watcher treats as “refresh now”. The picker popup is deliberately outside that scheme: display-popup -E runs in a hidden pane list-panes -a never 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

agentstoretailserved
claude~/.claude/projects/<cwd-slug>/<session>.jsonl, plus <session>/subagents/agent-*.jsonlbyte offsetyes
codex$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<iso>-<session>.jsonlbyte offsetyes
gemini~/.gemini/tmp/<projectHash>/chats/session-<iso>-<short>.jsonlbyte offset, with $set droppedyes
pi~/.pi/agent/sessions/--<cwd-slug>--/<iso>_<session>.jsonlbyte offsetyes
OpenCode~/.local/share/opencode/opencode.db (SQLite)event.seqyes
cursor-agent~/.cursor/projects/<cwd-slug>/agent-transcripts/<chat>/<chat>.jsonlbyte offsetno

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.

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