Agent Attention — One Model, One Place
When an agent finishes or asks a question, kolu can play a sound, show a popup, badge the app icon, and mark the dock and host tab. Today those rules live in three modules split by which host you're looking at, so the same terminal behaves differently depending on focus. This note picks one word for it — attention — writes down the rules kolu should follow, and puts them in one place.
When an agent finishes a turn, or stops to ask you a question, kolu can react in several ways: play a sound, show an OS popup, badge the app icon, put a mark on the host tab, and color the dot in the dock. All of that is one thing: getting your attention.
The reactions themselves are fine. The problem is where the rules live. They’re spread across three modules, split by one thing that shouldn’t matter: whether you’re currently looking at that host.
- The host you’re on uses one module.
useTerminalAlerts— plays sound, shows a popup, marks unread, and treats a finished agent as noteworthy. But only for the active host (activeId()/document.hasFocus()). packages/client/src/terminal/useTerminalAlerts.ts - Every other host uses a different module, with different rules.
useHostAttention— popup + app-icon badge, but only for an agent asking you (awaiting_user); it ignores plain finishes. packages/client/src/host/useHostAttention.ts - “Have I seen this yet?” is tracked for the current host only.The unread mark lives in per-host view state and is only written for the active host. packages/client/src/hostScope/createViewState.ts
So a finished agent on a background host tells you nothing, while the same agent on the host you’re looking at plays a sound. That split is why the whole thing is hard to reason about.
One word: attention
The code calls this three different things — alerts, attention, notifications — which is half the confusion. Pick one: attention (the system that decides when a terminal needs you, and how to show it). The renames that follow:
useTerminalAlerts+useHostAttention→ fold into oneuseAttention.- the
activityAlertssetting →attention(a stored key, so it needs a small rename-with-migration). fireActivityAlert→ an internal step insideuseAttention.
Keep the word notification for one thing only: the OS/browser popup — that’s the
platform’s own word (the Notification API). It’s a channel attention uses, not
the concept.
What you should experience
There should be one set of rules, the same on every host. Whether you’re looking at a host only changes one thing: whether the terminal counts as already seen.
| Agent state | Sound + popup (once) | On-screen marks | Goes away when |
|---|---|---|---|
| Asking you (waiting for input) | if not already seen | violet count pill · violet-washed row + tab · full-strength glowing pip · a chip counting how long it has waited | you answer — nothing else |
| Just finished (no input needed) | if not already seen | amber count pill · amber-washed row + tab · amber pip badge | you look at it |
| Working (or settling, or a shell that is printing) | — | a plain rust ACTIVE count + spinner | it stops moving |
| Idle | — | — | — |
The counts share one shape — identical geometry — and split by hue, not by
intensity: violet for asking (blocked on you, a state) and amber for an unseen
finish (an unopened obligation). Hue carries the meaning so neither mark has to be
the quiet one.The amber count reads the cross-host unseen-finished mark useAttention publishes, not the raw finishedIds — a finished agent idles in waiting ~forever, so “has any finished agent” would light every host permanently. The unseen rule is the pure, unit-tested nextUnseenFinished.
Two rules keep the vocabulary honest, and both are about what a mark promises:
- A pill means you can act on it. The two actionable counts wear a capsule and are real buttons — clicking one jumps to the next terminal it counts, literally that set: the section fold returns the ids it counted and the click walks them, so a capsule cannot promise a target the click can’t reach.It could: the count deliberately folded rows the activity window had parked, while the click re-filtered the visible rows — so the flagship case, an agent blocked long enough to fall out of the window, rendered a violet “1” that did nothing at all. Active is informational, so it is a bare number. A number that looks like a badge and does nothing teaches you to ignore badges, which is the failure mode the whole system exists to avoid.
- A count clears only when its fact does. Clicking a violet pill navigates; it
never dismisses. Needs-you is a live reading of what is blocked, so only the
agent leaving
awaiting_usercan clear it — an acknowledgement button would let the count lie.
Both surfaces put the mark on the whole row, not a dot on it (#1990). Unseen-finished washes the entire host tab amber; an unread dock row washes amber and fills its 3px leading stripe. The earlier design hung a 6px dot off the tab and a 7px corner badge off the dock pip — and the dock badge already pulsed, which is the useful evidence: unread threads still went unnoticed for hours. Motion on a 7px object doesn’t buy attention; area does. Doubling a dot’s diameter is a 4× area change, while tinting the surface behind it is ~100×. The pip’s corner badge stays as the precise mark — it just isn’t asked to do the catching.
The only place “which host” enters is seen: a terminal you’re staring at is seen by definition; one on a background host isn’t. Everything else is identical across hosts.
Why it’s tangled today
Two parts of this are already clean and should stay:
- Deciding the state — one shared file already maps every agent state to a coarse class. packages/terminal-vocab/src/agentProjection.ts
- Delivering a popup — one file owns the actual sound / OS-notification plumbing. packages/client/src/attentionNotify.ts Nothing wrong here.
The tangle is the middle — the rules (fire once, only if unseen, roll up per host, badge the app). That middle is what got split three ways by the active-host line. Nobody planned that split; it grew.
The fix — one owner
Pull the three rule-fragments into a single useAttention module that:
- reads every host’s agent states (the shared fact the host tabs already use),
- keeps one “seen” record across all hosts (not just the active one),
- emits what each screen cue needs — the per-host marks, the dock dots, the app badge, and a “new, unseen” signal for the sound/popup.
Then useTerminalAlerts and useHostAttention disappear into it. The active-vs-other
split is gone, because “seen” becomes a simple per-terminal check instead of two
separate code paths.
Built
Shipped in #1931. The three rule-fragments now live in one
packages/client/src/attention/useAttention.ts, wired once in App.tsx;
useTerminalAlerts, useHostAttention, and the old activity-alert delivery module
are gone. Every host runs the same rules off its urgency cell (now carrying
finishedIds too), so — falling out for free — a background host’s finished agent
now pings and shows a quiet host-tab mark, exactly like the active host’s. The
activityAlerts setting was renamed attentionAlerts (with a migration), and the
“needs you now / just finished” decision is the pure, unit-tested
attentionTransitions.
One vocabulary, every altitude
Shipping the one owner did not give the one vocabulary. A year of surfaces had each hand-rolled its own presentation of the same facts, and the result was a 20-hour miss: an agent sat blocked on a question behind a needs-you pip drawn at 55% alpha, six repo headers wearing pill-shaped row-tallies that looked exactly like notification badges, and a violet host-tab count that could not be clicked. The real signal rendered quieter than the decoys.
The cause was structural, so the fix is too — one fold, one component, and the old spellings deleted so drift cannot recur (#2019):
- The paint fold split its two violet states.
agentPaintClasshad foldedawaiting_userand post-turnwaitinginto oneawaitingclass, so “answer me” was defined to render as quietly as “just finished”. Nowawaitingmeans blocked-on-you at full strength — the same thing it already meant inagentBucket— and a newlingercarries the dim post-turn cue.Thesatisfies neverfences did the work: splitting the union compile-failed every consumer (pip variants, tile aura, minimap buckets, dock ranking), forcing each to state a decision rather than inherit a default. - One
<AttentionTriplet>renders the summary everywhere — host tab, host switcher, phone host row, and both dock section headers — replacing two bespoke pill components and a count capsule that were deleted, not deprecated. - The urgency cell carries
workingIds, so a background host’s tab can show its in-flight count; the marks store keeps asking ids rather than a count, so the pill’s jump target and its number cannot disagree. - One predicate decides “is something happening”. Four had grown up — the
counts read
agentBucket, the pip’s colour readagentPaintClass, its motion read a third rule, and padi’s urgency fold a fourth — and every bug filed against 2019 was two of them disagreeing.A tab reading 2 beside three moving marks (a working agent kolu held no state for); a violet mark spinning beside a tab counting nothing (an agent still settling after its turn); a ⌘K palette showing every background host’s terminals as idle (it fabricatedisLive:falseon the belief that activity was an active-host-only fact — it never was).attentionClassnow partitions a terminal (asking · working · linger · finished · idle), and two predicates answer over that class plus raw byte motion:attentionActive(does the mark MOVE — the pip) andattentionCounted(does a scope COUNT it — every tab and header, which isactiveminusasking, since asking has its own violet leg). The class is derived once, by padi; the client reads it back off the wire’s id-lists (frameClassOf) rather than recomputing it from a terminal’s own metadata, andagentPaintClassis that same partition renamed for the paint vocabulary — so there is one switch over the agent-state literals in the whole stack, and the tab, the pip and the section header cannot be reading two answers that arrived on two subscriptions a tick apart. That the host-level fold and the per-terminal predicate agree is a test, not a convention. Two consequences worth stating:lingerIdsjoins the wire (the turn is over but output is still landing, and the mark is still moving), and a printing shell with no agent counts — it has no agent to ask, and its bytes are the only evidence there is. - Order finally promotes the blocked. The dock had left ordering to pure recency on the theory that colour and animation carry attention — the miss is the counter-evidence. A genuinely blocked row now floats to the top of its section, and shows how long it has waited where its timestamp would sit.An awaiting row’s timestamp was previously hidden, because effectively-active rows suppress “Xs ago” as noise — true for a working agent, exactly wrong for one that has been blocked since yesterday.
Honest “finished” (EF2)
finishedIds is effective quiet, sticky per waiting episode: active + agent
waiting + first quiet-crossing (~5s with no meaningful output), then held until
the agent leaves waiting. Background sub-agents still typing hold the finished
chime; idle TUI statusline ticks after finish do not un-finish and re-chime.
Live dots and finish both ride kaval’s resize-aware activity edge — track EF
in Effective Finish — Quiet Chronology, Phased
(#1945 EF1, #1947 EF2).