Install agent hooks
Wire an agent so it reports state through hooks instead of screen detection alone. Every agent below follows the same three commands: install, verify, uninstall. What differs is where the config lives and, for some agents, a one-time trust step you must do inside the agent itself.
tma install-hooks is idempotent and additive: it prints a diff and asks before
writing, preserving unrelated config. Pass --yes to apply without the prompt.
The hook-event wiring points at the tma-hook wrapper, never the binary directly,
so rebuilds never break it.
The statusline context shim (opt-in)
One piece of wiring is not installed by default: the statusline context shim for
Claude and Cursor. It is the only edit tma makes to a value you already own —
your statusLine command — rather than adding tma’s own keys beside it, so you
have to ask for it:
tma install-hooks claude --statusline # wire it
tma install-hooks claude --no-statusline # remove it, restoring what it wrapped
It buys one thing: the context-window gauge (@agent_tokens), which the compact
action gates on. That metric appears in no hook payload — the statusline payload
is the only place an agent reports it. Skip the shim and everything else works
unchanged, because state, jumps and notifications all come from the hook events.
Because an agent’s statusLine takes exactly one command, the shim composes
rather than replaces: it reads the payload once, forwards a copy to tma event --kind context in the background, and pipes the same bytes to the command you
already had, whose output is still what gets rendered. It cannot go through the
tma-hook wrapper for that reason, so it embeds the resolved tma path with a
$PATH lookup behind it: [ -x "$_TMA_BIN" ] || _TMA_BIN=tma. Move the binary
without a $PATH entry and the context gauge stops while your statusline keeps
working, which is the failure this shape is chosen for.
If you would rather own the composition yourself, point statusLine at a script
of your own that calls tma-hook <agent> context alongside your real statusline,
and leave the shim uninstalled — tma-hook is generic over the event name, and
tma event falls back to $TMUX_PANE from the environment.
You state the choice once. --statusline records the agent in
statusline-state.toml in tma’s config dir, so a later plain install-hooks
keeps the shim current (re-pointing it at a moved binary) instead of reporting it,
and --no-statusline clears the record along with the shim.
--check reads the same record: with no flag it passes for an agent that opted in
and for one with no shim, and reports only a shim nobody asked for — which is what
an install from before this release looks like. --check --statusline requires the
shim regardless; --check --no-statusline requires its absence.
For which states each agent’s hooks cover and why, see
Agent coverage. For every flag and path
override, see tma install-hooks.
Claude Code
Config: ~/.claude/settings.json (hooks block).
tma install-hooks claude
tma install-hooks claude --check
tma install-hooks claude --uninstall
--check reports tma: hooks OK when the wiring is complete. No trust step:
Claude loads the hooks on next start.
Answer Claude’s prompts over the hook lane
Claude’s PermissionRequest hook can hand back a decision, not just a stamp. With
the lane on, tma act approve on a blocked claude pane returns a structured allow
to the hook that is holding the call open, and the tool runs with no keystroke
landing anywhere. Installing claude’s hooks is all it takes: the lane is on by
default, at a 25-second hold.
Turn it off by setting the key to false:
[hooks]
claude_reply_lane = false
Or set your own hold with the table form, { hold_ms = 12000 }: anything from 1000
to 590000 ms, since the ceiling has to stay under claude’s own hook timeout (see
[hooks]).
Nothing gets reinstalled either way. The hook reads this on every fire, so the next
permission prompt is already on the setting you just wrote.
What the hook does with it on: the same stamps as before (blocked / permission
and the pending-call trio), then it mints the request’s id, stamps it on
@agent_permission_request, writes a 0600 record of the pending call under
$XDG_RUNTIME_DIR/tma/requests/, and holds. A verdict written inside the hold goes
back to claude as its own decision object and the hook exits 0. A hold that expires
deletes its record, prints nothing, and exits 0, which hands the prompt straight
back to claude.
Nothing is hidden and the keyboard never stops working. Claude draws its dialog
the moment it asks and does not wait for the hook, so the hold changes nothing about
what is on the screen. Screen detection still reads blocked, tma act approve --pane still sends 1 when no hook is holding, and typing at the pane answers it
the way it always did: on Claude Code 2.1.261 a 1 pressed half a second into a hold
resolved the call in 50 ms with the hook still parked. A pane nobody answers over the
lane behaves exactly like a pane with the lane switched off. That is the guarantee,
not a fallback: the lane can only add a way to answer, never take one away.
What being on by default costs is one sleeping tma event process per permission
prompt you answer by hand, until its hold expires. If that is not a trade you want
on a given machine, claude_reply_lane = false is the switch.
Answering one
Find the blocked pane and the request it is parked on, then answer that exact request:
tma ls --json | jq -r '.agents[] | select(.state=="blocked") | "\(.pane) \(.permission_request)"'
tma act approve --pane %7 --expect-permission-request 6f1c2a09d4b7e310
Exit 0 means the hook took the verdict: outcome replied, and kind reads hook
in the act audit log. Exit 4 with
request-gone means the prompt turned over between the read and the dispatch, so
the id the pane carries is no longer the one you quoted and nothing was sent. Adding
--dry-run to that same command says which arm it would take right now, naming the
held request when a hook is parked on one and the key sequence when none is.
v1 covers claude and no other agent. OpenCode answers its own prompts over HTTP instead (the API lane), and every other agent still gets keystrokes.
OpenCode
Config: a JS plugin in ~/.config/opencode/plugin/tma.js.
tma install-hooks opencode
tma install-hooks opencode --check
tma install-hooks opencode --uninstall
The plugin forwards OpenCode’s event-bus events to tma-hook. There is no
session-end event, so deregistration rides pane close rather than a hook; nothing
you need to configure.
Codex CLI
Config: two channels, both written at once: notify in
$CODEX_HOME/config.toml and a Claude-style $CODEX_HOME/hooks.json (default
~/.codex/).
tma install-hooks codex
tma install-hooks codex --check
tma install-hooks codex --uninstall
Caveat: the installer prints a trust step, and it is load-bearing:
tma: codex trust gate: the hooks.json entries stay INERT until you open codex, run /hooks, and trust the tma-hook entries (codex silently skips untrusted hooks). Codex pins that trust to the exact command string, so an install that CHANGES it (an [install] wrapper_ref switch, a moved wrapper) has to be trusted again. The notify signal works without this step.
tma: installed hooks for codex
Codex silently skips any untrusted hook, so after installing you must open codex,
run /hooks, and trust the tma entries before the hooks.json events fire. Trust
is recorded against the hook’s exact definition (a trusted_hash per entry in
~/.codex/config.toml), so any change to the command string means re-trusting:
moving the wrapper, and also switching [install] wrapper_ref between bare and
absolute. That is the one real cost of migrating an old absolute install to the
bare default, and it applies to codex’s hooks.json only. The notify channel
is a plain config value and is not trust-gated, so idle detection works
immediately.
Codex allows exactly one notify program, so a tool that wants the signal
(Codex Computer Use, for one) takes the key and passes what was there before to
itself, as a JSON array in its own --previous-notify argument. tma reads that:
a chain carrying tma-hook codex notify is wired, and --check and tma doctor
report notify chained through <program> instead of a missing entry. Install
leaves a working chain untouched. If the chained command names a wrapper this
build no longer writes, install refuses and names the edit rather than rewriting
another program’s argv; fix the reference in config.toml by hand.
Gemini CLI
Config: ~/.gemini/settings.json (hooks object, same shape as Claude’s).
tma install-hooks gemini
tma install-hooks gemini --check
tma install-hooks gemini --uninstall
Caveat: Gemini gates local config behind a per-folder trust prompt:
tma: gemini folder-trust gate: the settings.json hooks load only after you trust the working folder in gemini (it prompts "Trusting a folder allows Gemini CLI to load its local configurations, including … hooks …" on first run there). Once the folder is trusted the hooks fire; there is no separate per-hook trust step.
tma: installed hooks for gemini
Trust the working folder when Gemini prompts on first run there; after that the hooks fire with no further step.
Cursor CLI
Config: two files. ~/.cursor/hooks.json carries the hooks (cursor’s own shape,
not the Claude shape); ~/.cursor/cli-config.json carries the statusLine
context shim. One command writes both, and --uninstall removes both.
tma install-hooks cursor
tma install-hooks cursor --check
tma install-hooks cursor --uninstall
Each file is parsed and rewritten on its own, so unrelated keys in either survive.
An absent cli-config.json is created on install and never created by uninstall.
Override the paths with --cursor-hooks / TMA_CURSOR_HOOKS and
--cursor-cli-config / TMA_CURSOR_CLI_CONFIG.
Caveat: the hooks are user-level. Cursor fires hooks only from
~/.cursor/hooks.json, not a project-level .cursor/hooks.json, so the wiring is
global to your user rather than per-repository. tma writes cursor’s schema
({"version": 1, "hooks": {"<event>": [{"command": "…"}]}}) and preserves any
unrelated hooks already there. Cursor exposes no permission hook, so blocked is
detected from the screen rather than a hook.
pi
Config: a self-contained JS extension at
~/.pi/agent/extensions/tma.js (default; $PI_CODING_AGENT_DIR/extensions/ if
set).
tma install-hooks pi
tma install-hooks pi --check
tma install-hooks pi --uninstall
Caveat: pi has no JSON hook block. It auto-discovers extension modules from
~/.pi/agent/extensions/, so tma drops a tma.js file there that subscribes to
pi’s events and shells out to tma-hook fire-and-forget. The extension is inert
outside tmux and never blocks pi. pi auto-runs tools with no approval prompt, so
there is no blocked state for it at all.
Verifying everything at once
A bare --check inspects every known agent plus the shared wrapper and tmux
hooks, and its exit code reflects drift (0 = wired, 1 = incomplete):
$ tma install-hooks --check
tma: hooks OK
If something is missing it names it, for example after an uninstall removed the tmux server hooks:
$ tma install-hooks --check
tma: hook wiring incomplete:
- tmux hook after-select-pane missing (config reload?)
- tmux hook session-window-changed missing (config reload?)
run `tma install-hooks <agent>` to reinstall
The tmux hooks are runtime server state, so a kill-server or a reboot drops them
even though the agent config is untouched. --check calls that case out
separately (“installed but not present on this server, likely restarted”); see
making the hooks survive a restart.
The attention-clear tmux hooks
tma install-hooks <agent> also installs two tmux server hooks so a pane’s
attention flag clears the moment you look at it, and again when you look away.
You do not add these yourself; they are shown here so you recognize them in
show-hooks:
$ tmux show-hooks -g | grep clear-attention
after-select-pane[0] run-shell "if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=after-select-pane '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=after-select-pane tma clear-attention '#{pane_id}' 2>/dev/null || true; fi"
session-window-changed[0] run-shell "if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=session-window-changed '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=session-window-changed tma clear-attention '#{pane_id}' 2>/dev/null || true; fi"
session-window-changed is the window half rather than the obvious
after-select-window, because tmux runs that one even when you select the window
you are already in — and the “window you left” it reports there is whatever
window you left however long ago, so the clear landed on a pane you had not
looked at since. tma removes any after-select-window entry of its own when you
re-run tma install-hooks. If you wired one by hand from an older version of
this page, check tmux show-hooks -g after-select-window and remove tma’s line.
The command names the binary tma was installed from and falls back to whatever
tma is on $PATH when that path is gone, so a rebuild or a move does not leave
a dead hook behind. tma install-hooks --check compares each installed hook
against the command this build would write and reports a mismatch as stale; the
next tma install-hooks <agent> rewrites it in place.
On every pane switch and every REAL window change, tma clear-attention drops
@agent_attention on two panes: the one you just moved to, and the one you just
left. So the done/blocked flag reverts to plain idle as soon as you jump to (or
manually switch to) that pane — and also when an agent finishes while you are
sitting there watching it and you then move on, which is the case an arrival-only
clear left marked for as long as you stayed away.
Clearing on departure does not touch the walk-away signal, and not by a threshold: leaving an agent running and going to lunch means you never navigate, so no hook fires and nothing clears, and navigation that moves nothing clears nothing anywhere. A pane switch in some other window clears only that window’s departed pane; every other flag stands.
Departure means a pane or a window, never a whole session: switch-client to
another session leaves the mark standing on the pane you were watching. The mark
comes down when you return: your first keystroke in that pane, or your next pane
or window switch inside that session.
That limit is a choice, and there are two hooks you could reach for if you wanted
to change it yourself. Neither is safe, in different ways.
client-session-changed fires the same for switch-client -t <the session you are already on> as for a real switch, and the session it names as the one you left is
stale on that no-op, so a departure clear there drops done marks in sessions you
had not been near. pane-focus-out has no such staleness — it fires on a real
session change and on none of the no-ops, naming the departed pane directly — but
it also fires when you cleanly detach, when any menu or popup opens over the pane
(including the prefix-a picker), and, with focus-events on, on every pane and
window switch as well. And it does not fire at all while another client is still
attached to the session you left — which, if you run the tma daemon, is always,
because the daemon parks a control-mode client on every session it watches. (It
also does not exist below tmux 3.3 unless focus-events is on.) tma installs
neither hook; if you wire one by hand, that is the behaviour you are choosing.
The hooks are not the only clear. If you never navigate at all — the agent
finishes under your eyes and you just keep typing at it — the poll cycle takes
the mark down on your next real terminal input, provided a client of yours is
displaying that pane and that input lands after the mark went up. Input means
anything your terminal genuinely sends, which with focus-events on includes the
focus report you generate by switching to another application. That needs no hook and
no install; it is the same walk-away rule read the other way round, and it is
what makes the invariant the done mark survives until your next input while that
pane is on screen, or until you navigate off it. Under a control-mode client
(iTerm2’s -CC) tmux freezes the client’s input clock at attach, so there the
hooks are the only clear you get.
TMA_HOOK_KIND in the command is how clear-attention knows which of the two
hooks fired, which is what tells it where the departed pane is: still in this
window, or back in the window you left. It is an environment variable rather than
an argument on purpose, so that a hook string written by a newer tma still works
against an older binary on $PATH — the older one does not recognize it, ignores
it, and clears the arrival pane as it always did.
An existing install keeps the old behaviour until you re-run
tma install-hooks <agent>. The hooks live in tmux server state, not in tma, so
upgrading the binary does not rewrite them; tma install-hooks --check reports the
old command as stale, and the next install rewrites it in place.
If your tmux has focus-events on, you can also clear attention on terminal focus
changes (switching into the tmux window from another app) by opting in:
[focus]
events = true
This installs an additional pane-focus-in hook. It is off by default because it
requires focus-events on to be set in tmux. It carries no TMA_HOOK_KIND: it
also fires when the client regains focus, which is not a departure from
anything, so it clears the arrival pane only.
Making the tmux hooks survive a server restart
set-hook writes runtime server state. A kill-server, a reboot, or the last
client detaching from a server started with exit-empty on takes the hooks with
it, and nothing reinstalls them: tma install-hooks is the only writer, and the
next tma command does not re-run it. tma install-hooks --check and tma doctor
name that state on its own (“installed but not present on this server, likely
restarted”) so it is not confused with never having installed them.
To make them durable, put the same commands in your tmux config, substituting your
own tma path:
set-hook -ga after-select-pane "run-shell \"if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=after-select-pane '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=after-select-pane tma clear-attention '#{pane_id}' 2>/dev/null || true; fi\""
set-hook -ga session-window-changed "run-shell \"if [ -x '/usr/local/bin/tma' ]; then TMA_HOOK_KIND=session-window-changed '/usr/local/bin/tma' clear-attention '#{pane_id}' 2>/dev/null || true; else TMA_HOOK_KIND=session-window-changed tma clear-attention '#{pane_id}' 2>/dev/null || true; fi\""
Two things matter here:
- Use
-ga, not-g. An unindexedset-hook -greplaces the whole hook array, so it deletes tma’s entry (and anyone else’s) on everysource-file, the same hazard--checkreports as a wiped hook. - Keep the command byte-identical to what tma installs. Copy it out of
tmux show-hooks -g | grep clear-attentionrather than retyping it:--checkcompares against the command this build writes, so a hand-shortened variant is reported as stale, and re-running install rewrites the runtime copy while your conf line puts the old one back at the next restart.
Alternatively, skip the conf entirely and re-run tma install-hooks <agent> after
a server restart; tma doctor tells you when that is needed.
What the last uninstall cleans up
Uninstalling the last wired agent also clears tma’s @agent_* pane options from
every pane on the server. Nothing refreshes them once the wiring is gone, so a
#{@agent_state} left in a border or status format would otherwise show one
frozen state forever.
One thing it does not touch: a status-line entry you added yourself, such as
set -g status-right '#(tma status)'
tma never wrote that line, so it never edits it; the uninstall prints a reminder and leaves your config alone.
Sharing one agent config between machines
By default every wiring names the wrapper by its bare name:
"command": "tma-hook claude Stop"
Every machine resolves that off its own $PATH, so a ~/.claude/settings.json
synced between a Mac and a Linux box works on both. Nothing to configure.
If your configs were written by a tma older than 0.6 they carry absolute paths instead:
"command": "/Users/you/.local/bin/tma-hook claude Stop"
That path is correct on the machine that wrote it and wrong on any other. It keeps
working where it was written, and --check does not report it as drift as long as
it resolves to the wrapper, so there is no hurry. Repoint everything already wired
when you want the portable form:
tma install-hooks --all
--wrapper-ref absolute writes paths again for a single run, and [install] wrapper_ref = "absolute" makes that the standing choice.
One caveat if you use codex: it pins its hooks.json trust to the exact command
string, so entries rewritten by --all are inert until you open codex, run
/hooks, and trust them again. Codex’s notify channel and every other agent are
unaffected.
--all covers the agents that already carry tma wiring, which is a different set
from the one tma init wires: init only touches agents whose launcher it finds on
$PATH, so an agent you have since removed from $PATH keeps its old wiring
through a re-run of init but is repointed by --all.
$HOME is deliberately not an option here. Half the wiring never reaches a
shell: Codex’s notify is an argv array, and the OpenCode plugin and pi extension
call spawn() directly, so $HOME/.local/bin/tma-hook would be taken as a
literal filename with a dollar sign in it. A bare name works everywhere because
execvp searches $PATH the same way a shell does.
What a bare name gives up is the guarantee that the wrapper is findable. A
GUI-launched editor often inherits a narrower $PATH than your shell, and a
wrapper an agent cannot find fails silently by design. Two things guard that:
install-hooks refuses to wire anything when tma-hook is not on the $PATH it
can see, and tma doctor reports the reference rather than the file:
wrapper: tma-hook ✓ on $PATH (/home/you/.local/bin/tma-hook)
A ✗ not on $PATH there means the wiring is intact and inert. Put the wrapper’s
directory on the agent’s $PATH, or set wrapper_ref = "absolute" and
re-install.
Agents that run in a container
Install the hooks where the agent’s config lives, which is inside the container, and give it the tmux socket plus the pane id so its events reach the host server. The full recipe, including the one identity carve-out that will otherwise wipe the pane’s state, is Run an agent in a container.
Overriding paths
For a non-default config location (test isolation, an XDG-relocated home), every
path has a flag and a matching environment variable, listed under
tma install-hooks. For example
--codex-hooks <path> / TMA_CODEX_HOOKS, --gemini-settings <path> /
TMA_GEMINI_SETTINGS, --pi-extension <path> / TMA_PI_EXTENSION.
Two more variables are read by the installed tma-hook wrapper itself, at fire
time rather than at install time, so setting either changes what an already-wired
agent does:
| variable | effect |
|---|---|
TMA_BIN | The tma binary to run. Taken only when it is set and executable; otherwise the wrapper falls back to a tma sitting next to itself, then to $PATH. That resolution happens on every fire, which is why a rebuild or a move never surfaces to the agent as a hook failure. |
TMA_HOOK_SOCKET | Pin the tmux server by name, as tmux -L <name> does. Unset, the wrapper passes no socket flag and tma uses the $TMUX the pane inherited, which is what you want for a normal install. It exists for the test suite and for setups running more than one server. |
Neither is written by install-hooks; export them in the environment the agent
starts in.