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

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.