Skip to content

Agent awareness: see and reach Claude Code / Devin sessions from tmux - #3

Merged
dsaad68 merged 14 commits into
mainfrom
hooks
Aug 23, 2026
Merged

Agent awareness: see and reach Claude Code / Devin sessions from tmux#3
dsaad68 merged 14 commits into
mainfrom
hooks

Conversation

@dsaad68

@dsaad68 dsaad68 commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Why

tmux cannot tell you which pane an AI agent is running in, let alone whether it
is waiting on you. #{pane_current_command} reports node for Claude Code, and
no process name distinguishes thinking from blocked on a permission prompt.

Both CLIs expose lifecycle hooks, and a hook process inherits $TMUX_PANE from
the 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.json serves both
    CLIs (Devin reads that file too); the agent is told apart from its environment.
    Installed idempotently by kaku-tab install-hooks.
  • An agent column in the picker — one glyph for which agent, one for what it
    wants: working, blocked on permission, asking, finished, failed.
  • An agent box under the cursor saying what it is actually doing — the prompt
    it is working on, the command it wants approved, the reply that ended the turn.
  • Two status-bar pills — how many agents want you, and how many are open.
  • kaku-tab go-agent — jump straight to whatever most wants you; press again
    for the next.
  • A desktop notification on the transition into a waiting state, never on the
    repeats. Off by default.
  • @kt_agent_win for a badge in the tmux window list, and %a to put the
    state in the terminal tab title.

How the state is stored

A tmux pane option, @kt_agent, with the message in a second one. Two
things fall out of that:

  • tmux list-panes already runs on every picker invocation, so #{@kt_agent} is
    one more field in a format string that was going to be evaluated anyway —
    agent awareness costs no extra process.
  • Staleness is structural. Close the pane and the record goes with it. No
    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_agent is only ever set with
-p. 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 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.

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

7b39100 and its revert 9fa8998 are a matched pair that nets to zero — a
layout 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

dsaad68 and others added 14 commits August 19, 2026 12:55
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>
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>
@dsaad68

dsaad68 commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Review

Reviewed the full diff and exercised the picker, the hook, the sweeper and the
status segment against an isolated tmux server. Six findings, all reproduced and
all now fixed in fda5d97.

severity finding
1 high the agent box pushed the frame off a short popup (60x17, the documented compact default) — footerLines grew to 11 rows while listHeight's floor still assumed 3
2 medium go-agent found nothing whenever @kaku-tab-scope was not all — it passed an empty SelfSession
3 medium a stale message could be redisplayed: the state tag distinguishes states, not turns
4 low the sweeper cleared @kt_agent but left @kt_agent_msg, turning (3) into a cross-session leak
5 low firstMeaningfulLine skipped fence markers but not fence contents
6 low a payload over the 1 MiB cap was dropped, stranding a pane in perm

Two of these are worth calling out beyond the fix.

(1) is the one that mattered. The overflow was a regression introduced in
this branch, and the comment warning about exactly that failure was already
sitting three lines above the clamp that reintroduced it. The footer is now
budgeted — the list keeps a minimum, the box takes what is left after the help
bar, and the message shrinks before the box is dropped, since
"waiting for permission" in three rows is most of the value. The reserve is also
held constant as the cursor moves; sized to the box currently on screen it
resized the viewport mid-keypress.

(6) was a correctness bug wearing a performance comment. PostToolUse is
precisely the event that clears a pane out of "waiting for permission" once you
approve a call, so dropping one to a large Read left the pane advertising a
request you had already granted — with go-agent obediently sending you back to
it.

Verified after the fixes: turn 2 reaching done with no text no longer shows
turn 1's reply; the sweeper takes both options; go-agent works under
scope=session; and the frame is exactly 17/20/40 lines at 60x17, 80x20 and
150x40 with the box rendered at each.

Coverage 69% → 70%.

🤖 Generated with Claude Code

@dsaad68
dsaad68 merged commit 176b392 into main Aug 23, 2026
5 checks passed
@dsaad68
dsaad68 deleted the hooks branch August 23, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant