The detection model
This page explains how tma decides what an agent pane is doing, and why it
trusts what it trusts. The exact option names and JSON keys are in
pane options and JSON contracts; the
per-agent evidence tables are in agent coverage.
The full arbitration record is kept in the repository’s docs/internal/ notes
rather than on this site.
Four states, plus detail, plus attention
The published state (@agent_state) is one of exactly four tokens: working,
blocked, idle, unknown. That vocabulary is closed and frozen. The reason
it stays small is that the only question every consumer actually asks is whose
move is it: the agent’s (working), the human’s (blocked), nobody’s
(idle), or unreadable (unknown). tma jump --blocked has to mean the same
thing for every agent, so the mappings from an agent’s own events into these
four tokens are normative, not something a manifest gets to redefine.
Prior tools reached for larger enums (six or seven states) and ended up
conflating orthogonal things. “Rate limited” and “error” are reasons, not
states; “done” is really “idle, and you haven’t looked yet”. tma splits those
onto two other axes so the state token stays stable:
-
@agent_detailis an open, additive token that qualifies the state (permission,rate_limit,compacting, and so on). It can be empty. A rate-limited agent isworking/rate_limit, because the agent auto-resumes and the ball is not with the human; an agent that halts asking for confirmation isblocked/permissionon its own prompt evidence.That
rate_limitsplit is implemented, not hypothetical. Claude Code waits out a usage limit in the open session and continues on its own, which isworking/rate_limit, and when the wait ends without continuing (the reset landed while the machine slept, or automatic continue was off) it sits there until you act, which isblocked/rate_limit. Both the hook claims and the screen rules carry it, per agent coverage. The detail is what makes that usable from a script:tma wait --until blockedfires for either kind of stop, and the token beside it says whether the pane needs a decision from you (permission,plan,trust) or merely needs the clock to come round (rate_limit). Poll it out oftma ls --json’sdetailkey and let the second kind wait. -
@agent_attentionis a presentation flag meaning “this changed and you have not seen it yet”. It is set on a noteworthy transition and cleared two ways. By navigation: on the pane you move to, and on the pane you move away from. And by input: the next thing your terminal sends at a pane a client of yours is displaying — usually a keystroke — if it lands after the mark went up. In one line, the done mark survives until your next input while that pane is on screen, or until you navigate off it. Nothing else takes it down. Walking away clears nothing, so leaving an agent running and going for coffee still leaves the mark waiting for you however long that takes; navigation that moves nothing clears nothing either, since selecting the pane or the window you are already in is not a departure. Navigation means a pane or a window: switching to another session does not clear, on purpose. A session is a workspace you come back to, and “which session did something finish in” is the questiontma statusandprefix-jexist to answer; leaving-means-seen is calibrated to the pane you were staring at, not to a workspace you walked out of. The mechanics agree: the notification tmux fires for a session change fires identically when the session did not change, and the one hook that can tell those apart also fires when you detach, when a popup opens, and not at all while any other client is attached to the session you left. A mark left standing on a session you walked out of is the safe half of that trade, and it comes down on your first keystroke back inside it. A finished agent isidlewith attention still set, which the surfaces render as the distinct done glyph. A mark that came down goes back up on the NEXT completion, and it has to be raised by the hook that reports the turn ending, not by the fold: the fold sees only states, and the second completion of a pane that never visibly worked in between is anidle→idleedge it cannot tell from a quiet idle pane. The manifest names that hook (turn_end), the intake stamps@agent_turn_atwhen it raises, and one turn end reported on two channels (codex sends bothStopandnotify) still marks one completion, because the second finds the mark already standing. Keeping done on this separate flag rather than making it a fifth state token is deliberate: the closedstatevocabulary stays four tokens, and a script readingstatenever has its value change shape under it. Attention is also not the notification record: navigating clears attention, but a blocked episode you glanced at and walked away from is still blocked, so the notifier keeps its own separate marker.The input half reads two facts tmux already keeps: which pane each client is displaying, and when that client last received real terminal input (
#{client_activity}, which moves for anything your terminal genuinely sends — a keystroke, the prefix key, the mouse, and the focus reports it sends whilefocus-eventsis on — and never for pane output or fortma’s own polling). The focus reports are worth knowing about: withfocus-events on, switching to another application counts as input, so a mark raised before you alt-tabbed away comes down. That is the same rule as the navigation half, where leaving a pane also counts as having seen it. It is an ordering against the raise, never a window: “you typed in the last N seconds” would eat the mark for the very case the mark exists to serve. Two limits are honest ones. A control-mode client (iTerm2’s-CC) has its activity clock frozen at attach, so under-CCthis half does nothing and navigation is the only clear. And a person who reads the output without touching the keyboard looks exactly like a person who is not there, so their mark stands until they type or move.
The same four distinctions, arrived at independently
The strongest evidence that these are the natural cuts is that an agent vendor
reached the same ones without reference to tma. OpenAI’s codex app-server
publishes a thread/status/changed notification whose ThreadStatus is
notLoaded, idle, systemError, or active carrying an activeFlags list of
waitingOnApproval and waitingOnUserInput
(codex-rs/app-server-protocol/schema/typescript/v2/ThreadStatus.ts). It maps
onto this vocabulary without loss: active with no flags is working, active
plus waitingOnApproval or waitingOnUserInput is blocked, idle is idle,
and notLoaded is a pane with no agent registered on it at all. systemError is
the case that argues for the axis split rather than against it: an error is a
reason, so it belongs on the detail axis, and it is the declared error detail
token rather than a fifth state.
The two flag names line up with tma’s detail tokens, though only one of them
exactly. waitingOnApproval is permission, the same distinction under another
name. waitingOnUserInput has no exact counterpart: the nearest token is
question, declared in tma-core and not yet emitted by any bundled manifest,
so the honest statement is that the two vocabularies agree on the distinction and
not on the word. Publishing the token set as something another tool can write to
follows from all of this, and is written down as the @agent_state
contract.
Three evidence sources, one ranking
tma learns a pane’s state from three kinds of evidence, in descending
fidelity:
- Agent hooks. A cooperating agent runs a command at each lifecycle point,
so it tells
tmait just blocked, at the instant it blocks, with zero inference. Highest fidelity. - Screen chrome. Capturing the pane and matching its on-screen text against the agent’s manifest rules. This is how a hookless agent, or a missed hook, still gets detected.
- Process facts and the pane title. The process walk (is the agent still alive?) and the OSC title the agent publishes. Output activity is not on this list: a pane producing bytes tells the daemon when to look, not what state to report.
They are combined by a deterministic fold, not a probabilistic fusion. The
sources have a natural strict ranking, and the verdict has to be explainable
(tma debug explain names the rule or event that decided), so weighting would
be both unnecessary and opaque. The order the fold applies is:
- a fresh hook event from a registered pane;
- visible blocker chrome on the live viewport;
- visible working chrome, which means
working; - visible idle chrome, which means
idle; - otherwise hold the previous state, or
unknown.
Two things stop the fold before it reads the screen at all. If the pane’s
foreground process is not the agent, the screen belongs to something else and
the verdict is capped at unknown. What that cap governs is the screen, not
what the agent said about itself: a pane already carrying a hook claim keeps it
as long as the agent’s own process is still in the pane’s tree. An agent that
hands the tty to $EDITOR or pipes a diff into a pager is alive and mid-task,
and dropping its blocked the moment vim comes up would lose exactly the
state you needed. A pane with no hook claim behind it has only the process walk
to go on, and that walk is stale while someone else holds the foreground, so it
still caps at unknown — as does a pane whose agent pid is gone, which is the
claim expiring on process evidence rather than on the foreground. If the
viewport is not the live screen, the
last state is frozen rather than matched against whatever is on display: a rule
written for the current prompt would happily match a prompt you scrolled back
to. That freeze keys on the scroll offset, not on copy-mode itself. tmux
reports offset 0 the moment you enter copy-mode, and at offset 0 you are still
looking at the live screen, so entering copy-mode to copy an error message does
not quietly suspend detection on the pane; scrolling up by a line does.
That is tmux’s scroll, and it is the only one tma can see. Agents draw on the alternate screen and scroll their transcript inside their own TUI, which moves no tmux fact, so scrolling back through a conversation in the agent does not freeze anything. It does not need to: those TUIs pin the chrome the rules match (spinner, composer, permission dialog) to the bottom of the screen, and it stays put while the transcript above it moves.
Why a hook can lose to the screen, and when it cannot
Ranking hooks first raises an obvious hazard: a stale hook claim outliving
reality. The fold handles this with coverage-aware decay rather than a blanket
timeout. A hook claim is expired by process evidence (the pid is gone, so the
agent died without firing its end hook) at any time. It is expired by screen
evidence only for states the agent’s manifest declares its screen rules can
actually see. A blocked agent can sit silent for ten minutes precisely because a
permission prompt produces no output, so the reconciliation sweep must never
read that silence as idle and flip a hook-reported blocked.
Silence, then, never expires anything. What can expire a claim is the screen
saying something else, and even that has to clear three gates at once: the
claim is older than its decay window, the manifest declares the claimed state
screen-visible, and this capture carries positive contrary chrome. blocked
gets its own, much longer window (blocked_decay_secs, five minutes against
hook_decay_secs’ sixty seconds) because answering a prompt takes as long
as it takes. It is a window rather than “never” for one failure mode: a
follow-up hook that never fired. Without a bound, one dropped event pins a pane
blocked for the rest of the session, and no amount of screen evidence, an idle
composer sitting there with the prompt long gone, could correct it. With the
bound, an agent whose manifest can actually read blocked off the screen (see
agent coverage) recovers on its own; one whose
manifest cannot, such as pi, keeps holding, because for that agent the absence
of blocker chrome carries no information.
The one case where blocker chrome overrides a live hook claim is decided by
evidence timestamps, not by “immediately” or “after a wait”. Visible blocker
chrome overrides a working or idle hook claim only when the stamped evidence
timestamp predates the capture. That single rule resolves the answered-prompt
race in both directions. Capture at T0 sees a prompt; the user answers; the hook
stamps working at T1. The capture’s blocked write carries time T0, which is
older than T1, so it is suppressed: the hook is newer evidence and wins. Reverse
the order and the capture is newer, so the block wins with no decay wait
(millisecond timestamps keep that ordering unambiguous; see the
pane options reference).
Identifying the pane
Before any of this runs, tma has to decide a pane is an agent pane at all. A
pane earns that identity two ways: by observation (the process walk finds a known
agent binary) or by self-registration (a hook stamped it). Observation is what
lets hookless agents show up without cooperation. Some agents run under a generic
process name (several launch as node), where the binary name alone would either
miss them or match every unrelated app; for those, a manifest adds
title_patterns that narrow a generic process match, so the pane is that agent
only when the process and the pane title agree. A hook registration is
authoritative and skips the title gate; title flicker is absorbed by holding the
last match while the pane’s agent pid is unchanged.
Narrowing shrinks the false-positive window but cannot close it: a dev server
whose title happens to match still looks like an agent. That pane, and only that
pane, opts out with tmux set-option -p @agent_ignore 1, after which it is
never identified, captured, or stamped, and any stamp it still carries is
cleared — no need to disable the whole agent type. tma doctor lists the panes
carrying it (see pane options).
Two kinds of pane are ruled out before the walk even runs, because for both the
walk would come back empty while the screen invites a false match. A remote shell
(ssh, mosh, docker, and friends) runs its real work on a host tma cannot
see. A nested multiplexer client (tmux, zellij, screen, dvtm, abduco)
is the same shape one level down: whatever runs inside belongs to the inner
server, not to this pane’s process tree, and the outer pane’s screen is a
composite of the inner ones that a screen rule would happily match by
coincidence. Neither gets a stamp or a row, and a stamp left on such a pane is
removed rather than trusted. tma debug explain names both (out_of_scope with
its kind); tma doctor lists the nested case, saying where the state actually
lives.
A live hook registration outranks both carve-outs. The carve-outs exist because
the walk comes back empty and the screen is somebody else’s; a registration is
positive evidence of the thing they infer the absence of — an agent fired a hook
in this pane, which it could only do from inside. So a registered pane keeps
its stamps and its row even when the foreground is docker or a nested tmux:
tma stops capturing it (nothing readable crosses the boundary) and lets the hook
path be its only evidence source, with the usual dead-registration reaper as the
liveness bound. That is what makes an agent in a
container work. Without a registration
nothing changes: an outer nested-tmux pane is as invisible as it always was.
Three tiers, none required
The same detection runs at three tiers. Each is a strict upgrade in latency or coverage, and consumers see no difference between them because they all read the same stamped options.
- Polling floor. Any one-shot invocation refreshes stale panes when it runs.
This is the only tier a hookless agent gets with no daemon, and it has no
driver of its own: something must invoke
tmafor stamps to stay fresh.#(tma status)instatus-rightis that required ambient driver; without it, ambient surfaces render nothing. - Hook tier.
tma eventdirect-stamps the moment a hook fires, with no daemon involved. State is event-latency, and a residenttma watchrefreshes within about a fifth of a second of a focus change: theafter-select-pane/session-window-changedhooks that already clear attention also walk panes for a watcher’s advertised pid (@tma_watch_pid, set on the watcher’s own pane so it dies with that pane) and sendSIGUSR1, which the watcher treats as “refresh now”. The picker popup is deliberately outside that scheme:display-popup -Eruns in a hidden panelist-panes -anever enumerates, so no hook can find it, and its own one-second refresh is what keeps it current. This is the sweet spot for a single-user setup: hook-fresh state, no background process. - Daemon tier. A background process holds control-mode clients, captures hookless panes on an activity-quiet edge, runs a slow reconciliation sweep, and dispatches deduplicated notifications. It adds cross-event intelligence, not basic liveness.
Deduplication is per state run, not per pane and not per episode. Whichever
process fires a notification stamps the time on the pane as
@agent_notified_at, and a notifier fires only when that marker predates the
pane’s @agent_since, which is written once per state. Five producers noticing
the same blocked run therefore ring once between them, while an agent that
blocks, gets answered, and later finishes rings twice (blocked, then done, if
you opted into done). The marker is a pane option rather than daemon memory on
purpose: a daemon restart mid-session must not re-announce every blocked pane
you already dealt with. Without a daemon nothing is resident to dispatch from,
so the hook path can fire for itself instead, opt-in via notify.from_event
(see notifications).
tma doctor reports which tier each pane is actually running at and why it is
not higher.
Reading a pane only when it can have changed
A capture is a capture-pane subprocess, and the poll cycle spawns them one
after another, so a session with a dozen agent panes pays for every one on every
cycle even when nothing has happened. The cost was measured against a release
build on a throwaway server of 40 panes, 10 of them agents (tmux 3.6, macOS,
arm64): a cold cycle that captures all ten takes about 104 ms, while the same
cycle with every stamp fresh, capturing nothing, takes about 24 ms. That is
roughly 8 ms of cycle time per agent pane, nearly all of it process spawn rather
than capture payload, and it grows linearly with the number of agents. The cycle
avoids most of that by asking tmux a cheaper
question first: #{window_activity}, the timestamp of the last output in the
pane’s window. When that timestamp falls strictly before the pane’s own
@agent_stamped_at, the screen behind the stored verdict is byte-for-byte the
screen a capture would return, so the cycle reuses the stamp and reads nothing.
The check is window-scoped, which is conservative in the useful direction: a
quiet window proves a quiet pane, never the reverse. tmux reports it in whole
seconds, so a write in the same second as the stamp counts as activity.
An unchanged screen is not the same as an unchanged verdict, because two of the
fold’s rules are driven by the clock rather than the screen. The dwell that
delays a working→idle publish resolves off idle chrome that is already on the
unchanged screen, so a working pane is always re-read. A hook claim past its
decay window can be expired by contrary chrome that has likewise been sitting
there since before the stamp, so a claim that old is re-read too. Inside its
window the claim holds whatever the screen says, and since a skip writes nothing,
the next cycle re-asks the same question against a later clock and captures the
moment either window closes. --debug-timing reports the skips as
capture-skipped next to the captures.
Why concurrent producers are safe
Several producers stamp the same pane options at once: a status poll in one client, another client’s poll, a hook firing, the daemon. tmux options have no transactions, no compare-and-set, and no writer identity, so an uncoordinated read-then-write loses races exactly on the transitions that matter, because hooks fire inside the read-to-write window.
The fix is to never decide client-side. Every guarded write is a server-side
conditional (set-option -pF), which tmux expands in the target pane’s context
atomically at write time. A capture producer’s state write carries a guard that
says, in effect, “only commit if a hook has not already claimed this pane with
newer evidence”. The whole chained write, state, provenance, timestamps, detail,
and the write-once transition marker, carries the same suppression condition,
so the tuple commits together or holds together. A losing producer changes
nothing, including the notification marker, so it cannot fire a stray alert
either. Everything that is not guarded this way is last-writer-wins over
deterministic values (the same fold, the same persisted inputs), which
converges.
Honest margins
Two properties are margins, not proofs, and the design says so plainly rather than dressing them up.
A margin is tolerable here only because the two directions of error cost
different amounts. A blocked agent shown as working or idle is the expensive
failure: you never go back, and the agent sits on its prompt until you happen to
look. A working agent shown as idle for a cycle costs you one glance. Where the
evidence is genuinely ambiguous the fold leans toward blocked. It stops short of
guessing, though, because a false blocked flag is expensive in its own currency:
flags that turn out to be nothing teach you to ignore the flag, and then the real
one goes unanswered too. So blocked is asserted only from direct evidence, a
blocked-class hook event or blocker chrome on the live viewport, and never
inferred from silence, from the pane title, or from a lull in output.
The daemon triggers a hookless capture on an activity-quiet edge, the moment a
pane stops producing output, because a permission prompt is exactly when output
stops. But the activity gauge sees %output events, not the kernel’s buffers,
so “quiet” is not proof that nothing is happening; it is a strong signal with a
settle window layered on top. The quiet threshold plus settle is a generous
empirical margin, chosen to be safely past real output bursts, not a structural
guarantee. Calling it a margin is the honest description. What the quiet edge
buys is a look rather than a verdict: it decides when to capture, and the
blocked call still has to come off chrome that is actually on the screen.
Pure event-driving fails open: a hook can be missed (the agent was killed with
-9, the hook was misconfigured, the daemon restarted mid-session). So state is
never only event-driven. The recovery paths are layered: process evidence
expires a claim whose pid is gone; a pane close clears state immediately; and a
low-frequency reconciliation sweep, the full poll cycle every 30 to 60 seconds,
rediscovers agents that never announced themselves and corrects any drift. The
governing invariant is that events drive state and the sweep repairs it, so
the sweep’s latency bounds only how long an anomaly can persist, never how fast
a normal transition is seen. Quitting an agent is a normal transition on that
reading: the daemon removes the pane’s stamp and recomputes both rollups on the
first quiet edge after the exit (the shell repainting its prompt is that edge),
and the sweep is the backstop for a pane no edge arrives on.
The numbered decision records behind this model live in the repository:
docs/internal/ARCHITECTURE.md
for the arbitration rules and
docs/internal/DAEMON.md
for the event sources and the daemon tier.