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

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.