Conversation
tmux cannot answer "is an agent waiting on me". #{pane_current_command}
reports `node` for Claude Code, and no process name distinguishes thinking
from blocked on a permission prompt.
So the agents report themselves. Both CLIs expose lifecycle hooks, and a
hook process inherits $TMUX_PANE from the agent that spawned it, which is
how the pane is known without guessing. `kaku-tab hook` maps the event to
one of five states and writes it to a tmux *pane* option.
Storing it on the pane rather than in a state directory buys two things:
it rides in on the list-panes query resolve already makes, so it costs no
extra process, and staleness is structural — close the pane and the record
goes with it. The one leak left is an agent killed outright inside a pane
that survives it, which the recorded pid covers.
New invariant, and the one way to get this wrong: @kt_agent is only ever
set with -p. tmux pane options inherit from window options, so a single
window-scoped write would have every agent-free pane in that window report
an agent. The per-window rollup is deliberately a different option name.
`kaku-tab hook` is inert by contract — never stdout, never a non-zero exit.
On PermissionRequest and the PreToolUse family both are decision channels,
so a status reporter that got either wrong would silently veto the user's
own tool calls.
Surfaces:
- an agent column in the picker (letter = agent, colour = state), rolled up
to window rows and session headers so a blocked pane three windows deep
is visible without unfolding; ^a filters to what is waiting on you
- an opt-in status-right counter, @kaku-tab-agents. It renders nothing when
no agent is running, and the hook pushes refresh-client -S so the count
moves as it happens rather than at the next status-interval tick
- `kaku-tab agents` from the shell, and an agent column in `resolve`
`kaku-tab install-hooks` merges one block into ~/.claude/settings.json,
which serves both CLIs — Devin CLI reads that file too, and the hook tells
them apart from the environment. Written in shell form with an explicit
exec: Devin's schema has no `args` field, so exec form there would invoke
the binary with no arguments and open the picker, and exec replaces the
wrapping shell so the recorded pid is the agent rather than a shell that
exits immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status segment was one unstyled count sitting next to a row of catppuccin modules, and it did not say what it was counting. Now it is two modules in catppuccin's own shape — rounded separator, icon on its own colour, value on the shared module background — reading how many agents are open and how many of them want you. The shape is built here rather than delegated to a catppuccin module because a real module always paints its icon and separators, including around an empty value, and this segment is empty most of the time. The palette is resolved from the live @thm_* options in a single display-message pass, so it sits flush against the modules beside it and still renders without catppuccin loaded. The second pill stays drawn at zero, greyed rather than hidden: a count that vanished would shift the first pill sideways every time an agent finished, which is exactly when you are looking at it. In the picker the column is now two cells — one glyph for which agent, one for what it wants — where before a single letter carried the agent and left the state to colour alone. Identity takes mauve and cyan, which no state uses, so the halves never read as one gradient, and busy is the only muted state: it is the one thing here you do not owe a response to. Counting and rendering move to internal/agent as pure functions over records and a plain-string theme. They were in cmd/kaku-tab, which .testcoverage.yml excludes as argument parsing — so the pill shape and the zero-state rule had no test. They do now, alongside a test pinning every glyph to one display cell: a double-width one would silently break the table's column budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plugin appends its segment to the end of status-right, which is the far right of the bar. That is a fine default but not always the wanted spot: a bar that ends in a battery module wants the pills somewhere inside it, not past it. Placing the segment by hand already worked — the plugin's idempotency guard sees it and skips appending — but nothing said so. Document it, with the -F warning that goes with it: -agF expands formats at load time, which runs the #() once and freezes its output instead of leaving it for the status bar to re-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The open-agent count was first and the notification count second. That is the wrong way round for the thing being scanned for: the notification count is the number that changes and the number you are looking for, and the open count is context for it. Swap them. The zero-state rule survives the swap unchanged, but its reason moves: the notification pill still stays drawn and greyed at zero, now so the pill *behind* it does not shift sideways every time an agent finishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on a real two-session list at 150x40: 28 of 40 rows blank, and the columns budgeted 22 cells for a label holding "1", 34 for one holding "zsh" and 46 for a 22-character path. Seventy cells of padding on every row, pushing the badge — the one column this tool exists to show — an eyeful away from the name it belongs to. Four changes, all of them the same idea: Columns are sized to their contents, once for the whole table, instead of by fixed proportions of the frame. Caps still bind on outliers so one 400-character path cannot squeeze the rest out, but in the common case none of them apply and the row is exactly as wide as what is in it. Columns whose every cell reads the same are not drawn. The pane count is "1p" on every row of a table with no split windows and the flags column is blank when nothing is flagged; both now disappear and reappear with the table. A session with one window renders as one row. The header carried the same badge and the same agent state as the single child directly beneath it, there was nothing to fold, and half the rows in a list of one-window sessions were that duplicate. Behind @kaku-tab-merge-single, on by default. The popup opens at the size the list needs, with the configured sizes as maximums. tmux fixes a popup's geometry at creation and cannot resize it, so this has to be decided before the picker is drawn — hence ui.Measure, which builds the model against an unreachable width so no cap binds and every column reports its natural size. The footer is counted at the width we will actually open at, not the measuring width: the help bar wraps, and sizing against a one-line footer opened a popup with three lines of it eating the list. Two smaller ones while in here: the agent identity and state glyphs get a space between them, because flush against each other they read as one smudged symbol and that defeats the point of splitting them; and the footer now spells out the selected row's agent in words, since nothing on screen said what a glyph meant. Same list as above now renders 3 rows in an 80x12 popup instead of 6 in 90x28. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts commit 7b39100.
Two changes salvaged from the reverted layout rework, both local to the agent column and neither touching the table's geometry. The identity and state glyphs were rendered flush against each other, where they read as one smudged symbol — the same failure the active-pane marker already had its own column to avoid. A space between them costs one cell of a column budget that is written in terms of agentCells, so the constant is the only edit. And nothing on screen said what a glyph meant. The footer now spells out the selected row's agent in words, on whichever row the cursor is on. It is one extra line, so footerLines() becomes the single source of truth for what sits below the list: listHeight has to reserve exactly the rows View draws, or the bottom row lands on the frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The footer said "claude · waiting for permission" but not what for, which is the only part you actually need in order to decide anything. Moving onto a row with an agent now opens a box below the list; moving off it closes the box again, so a list with no agents in it looks exactly as it did before. Four of the five states can say something real, because the hook payload that set them carries text: UserPromptSubmit has the prompt, PermissionRequest has the tool and its argument, Stop has the reply that ended the turn, StopFailure has the error type. Notification carries a type and nothing else, so `ask` has no message and the box simply omits the line. The text lives in a second pane option rather than a field of @kt_agent. That record's format is only safe because every field comes from a fixed alphabet, and a message is free text. It is stored tagged with the state it describes and dropped on read when the two disagree. That is what stops a permission request from still being displayed after your approval has moved the pane back to busy — the option is still there, but it belongs to a state the pane has left. It also means events carrying no text can leave the option alone, so a prompt survives a whole turn of tool calls without every PostToolUse having to re-read and rewrite it. Control characters are stripped before storing: the value is read back through the \x1f-separated format string the rest of internal/tmux depends on, and a newline in an assistant reply would shift every later field. This is the one part of kaku-tab that stores what you typed, so it is opt-out via @kaku-tab-agent-message, which keeps the box and drops the message line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The box rendered its own first outing back at itself. A Stop payload carries last_assistant_message, which is a whole markdown document — paragraphs, a code fence, and in that case a rendered box — and flattening all of it onto one line produced nonsense. Worse, the box-drawing characters in it landed inside the picker's box and read as a broken border, so the bug looked like a layout fault rather than a content one. Take the first line that says something instead: skip blanks, code fences and pure line-art rules, shed a leading markdown marker so the text starts at a word, and drop any Box Drawing or Block Elements rune that survives into the kept line. One stray ╮ is enough to masquerade as a border. Bullets need their trailing space to qualify as markers, so "Bash: rm -rf ./build" and a leading flag both survive intact — a permission message is the one that most needs to be reproduced exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three packages had gaps where a regression would have been silent. internal/model was at 0%. It holds the satellite naming rules, and a satellite is tied to its base by name alone — IsSatellite decides whether a grouped session's windows are listed once or twice, and BaseSession decides whether a client counts towards the session it belongs to. The two now have to agree: anything IsSatellite accepts, BaseSession must reduce to a shorter name that is not itself a satellite, or a rename leaves a tab pointing at a session that is not there. internal/ui/search.go was at 0% — a whole feature, untested. Case-insensitive substring matching, the 2000-hit cap that stops a one-letter query from building a slice of every line in every pane, the cursor clamp when a narrowing query shrinks the results out from under it, and highlight actually wrapping the match. That last one needs a colour profile forced: lipgloss renders plain with no terminal, so a styled string is otherwise indistinguishable from an unstyled one and the assertion passes vacuously. updateKey was at 7.6%, which is where regressions bite — it is the whole interaction surface. Navigation clamps, fold and unfold from both a header and a child, the detached and agent filters including the message that explains an empty result, and the state round trip a preview-toggle relaunch depends on. Those tests found a real bug, fixed here: both the query and the rename buffer backspaced by byte. One press over a multi-byte character left half a rune — "\xf3\xb0\x9a" from a nerd-font glyph — invalid UTF-8 that renders as mojibake and matches nothing. In a picker aimed at nerd-font and CJK window names that is the common case, not the edge, and it is the first invariant in CLAUDE.md. Thresholds ratcheted to just under where the tree now stands: total 40 -> 65, ui 40 -> 68, resolve 85 -> 88, agent 90 -> 95, and model added at 90. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six additions, all of them about the gap between "an agent wants you" and "you are looking at it". kaku-tab go-agent jumps straight to whatever most wants you, and pressing the key again walks to the next one. Ranked the way the picker ranks: blocked before failed before finished, oldest first within a rank, since the one that has been waiting longest is the one being kept waiting. Before this, reaching a blocked agent was four keys through the picker. The per-window rollup is finished. @kt_agent_win was written only by a manual `agents --refresh` that nothing called and nothing read — a half-built feature. It now refreshes whenever a pane changes state, in one query, writing only the windows whose value actually changed, and the docs carry a window-status-format snippet that consumes it. That query reads @kt_agent_win in *pane* scope on purpose. Pane options inherit from window options and no pane sets this one, so every pane reports its window's value — the same inheritance that forces @kt_agent to be pane-only, used deliberately here rather than tripped over. A desktop notification fires on the transition into a waiting state and never on the repeats, so a turn of tool calls does not re-notify you about a permission you already granted. Off by default. The body is an agent's own output, so it goes to osascript as an argument rather than spliced into the script: a quote or backslash in a reply would otherwise close the AppleScript string literal and let the remainder run as script. Knowing the previous state is what makes all three of those cheap. The hook now reads what it is about to overwrite, and notifying, refreshing the rollup and redrawing the counter all happen only on a real change — which steady-state PostToolUse traffic is not. `ask` stops being the one state with no message: the Elicitation event carries the question an MCP server is asking. The reference does not pin down which field holds it, so every plausible name is read rather than one guessed at. %a puts the state in the terminal tab title, so a blocked agent is visible with tmux not even on screen. And a busy record untouched for thirty minutes now reads as "no activity" in amber — every hook event refreshes the timestamp, so one that old has not made a tool call in half an hour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A header inherits its agent from its children, and the child carrying it is the very next line. With a list of one-window sessions that meant the same pair drawn twice, one row apart, on every session — three glyphs of ornament before each window index and two more above it. The record stays on the header row, so folding a session and resting on it still opens the agent box. Only the drawing goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six findings, all reproduced by the reviewer and all verified fixed against a real tmux server. The agent box could push the frame off a short popup. footerLines grew to eleven rows while listHeight's floor still assumed three, so View emitted more lines than the popup had and the title and prompt scrolled away — at exactly the documented compact default, 60x17. The footer is now budgeted: the list keeps a minimum, the box gets what is left after the help bar, and the message shrinks to fit before the box is dropped, because "waiting for permission" in three rows is most of the value. That budget is also held constant as the cursor moves. Sized to the box currently on screen, it resized the viewport under your hands: arrowing onto an agent row scrolled the list several rows in one keypress. Room is reserved only when the table has an agent at all, so an agent-free list is untouched. go-agent found nothing whenever @kaku-tab-scope was not "all" — it passed an empty SelfSession, and the session and group scopes compare against it, so every window fell out of scope while an agent sat blocked. It now takes the client's session like the picker does. printResolve, which had the same latent gap, is pinned to "all" instead: it is the debugging view of the join and a scoped one would hide the rows you opened it to see. A stale message could be redisplayed. The state tag distinguishes states, not turns, so a second Done reached without any text — an agent_completed notification, or a Stop with an empty reply — showed the *previous* turn's reply as if it were this one's. Entering a state with nothing to say now clears the message; staying in one still leaves it, which is what lets a prompt survive a turn of tool calls. The sweeper cleared @kt_agent but left @kt_agent_msg, which turned that into a cross-session leak: the next agent to occupy the pane and reach the same state without text of its own displayed the dead one's reply. firstMeaningfulLine skipped fence markers but not fence contents, so a reply opening with a diff or a snippet put a stray line of code in the box. Fenced blocks are now skipped entire, with the first line inside one kept as a fallback for a reply that is nothing else. And the 1 MiB payload cap could strand a pane. Truncation is not graceful here: a cut payload fails to parse and the event is dropped, and PostToolUse is precisely the event that clears a pane out of "waiting for permission" once you approve a call. Losing one to a large Read left the pane advertising a request you had already granted, with go-agent sending you back to it. Also noted by the review: m.status replaces the whole footer and was only ever cleared by ctrl+a, so one message left standing hid the help bar and the agent box until something overwrote it. Any keypress now dismisses it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ReviewReviewed the full diff and exercised the picker, the hook, the sweeper and the
Two of these are worth calling out beyond the fix. (1) is the one that mattered. The overflow was a regression introduced in (6) was a correctness bug wearing a performance comment. Verified after the fixes: turn 2 reaching Coverage 69% → 70%. 🤖 Generated with Claude Code |
Why
tmux cannot tell you which pane an AI agent is running in, let alone whether it
is waiting on you.
#{pane_current_command}reportsnodefor Claude Code, andno process name distinguishes thinking from blocked on a permission prompt.
Both CLIs expose lifecycle hooks, and a hook process inherits
$TMUX_PANEfromthe agent that spawned it. So the agents report themselves, and nothing has to
be inferred.
What
kaku-tab hook— one hooks block in~/.claude/settings.jsonserves bothCLIs (Devin reads that file too); the agent is told apart from its environment.
Installed idempotently by
kaku-tab install-hooks.wants: working, blocked on permission, asking, finished, failed.
it is working on, the command it wants approved, the reply that ended the turn.
kaku-tab go-agent— jump straight to whatever most wants you; press againfor the next.
repeats. Off by default.
@kt_agent_winfor a badge in the tmux window list, and%ato put thestate in the terminal tab title.
How the state is stored
A tmux pane option,
@kt_agent, with the message in a second one. Twothings fall out of that:
tmux list-panesalready runs on every picker invocation, so#{@kt_agent}isone more field in a format string that was going to be evaluated anyway —
agent awareness costs no extra process.
TTL, no sweeper, no directory to watch. The one case that does not self-heal —
an agent killed outright inside a surviving pane — is covered by the pid in the
record.
New invariant, documented in
CLAUDE.md:@kt_agentis only ever set with-p. Pane options inherit from window options, so a single window-scoped writewould have every agent-free pane in that window report an agent. The rollup is
deliberately a different option name.
kaku-tab hookis inert by contract — never stdout, never a non-zero exit. OnPermissionRequestand thePreToolUsefamily both are decision channels, so astatus reporter that got either wrong would silently veto the user's own tool
calls.
Tests
Coverage 40% → 69%, with thresholds ratcheted to match. Along the way the new
tests found and fixed a real bug: the search query and rename buffer backspaced
by byte, so one press over a multi-byte character left half a rune — invalid
UTF-8 that renders as mojibake and matches nothing. In a picker aimed at
nerd-font and CJK window names that is the common case.
Note on history
7b39100and its revert9fa8998are a matched pair that nets to zero — alayout rework that made the picker worse and was backed out. Left in rather than
rebased away, since the branch was already partly pushed.
🤖 Generated with Claude Code