You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Twenty-one of the twenty-five panes on this laptop are running an agent. Eight Codex, thirteen Claude Code, across three sessions. The thing that breaks down at that scale is not reading a pane, it is remembering which pane is which. You come back after lunch and you have to open four panes to find the one you cared about.
What the panes actually say right now
Read off the live workspace:
1yegabiz:1.1 codex [seoul-wedding-api]
1yegabiz:1.2 codex [seoul-wedding-api]
saas:1.1 codex [shortlist]
saas:2.2 codex [shortlist]
saas:4.1 codex [shortlist]
saas:4.2 codex [shortlist]
saas:2.1 claude [✳ Claude Code]
saas:3.2 claude [✳ Claude Code]
1yegabiz:2.1 claude [✳ Separate worktree]
1yegabiz:5.1 claude [✳ Separate worktree]
1yegabiz:3.1 claude [✳ #339 venue filter]
1yegabiz:5.2 claude [✳ PR 364 review]
Two different failures. Codex titles itself with the folder name, so four panes doing four different things all read shortlist. Claude Code writes a live task summary, sometimes useful (#339 venue filter), but it degrades to ✳ Claude Code before the first task and to vague nouns like Separate worktree after it.
The design
Read the screen, not the transcripts
A cheap model reads the tail of each agent pane's scrollback and writes a short title. That is the whole feature.
This is the same rule the rest of remux already runs on, and #35 states it outright: never parse agent output, just read the screen. It buys the same things here it bought there. One code path covers Codex, Claude Code, opencode, crush and whatever gets installed next year. No vendor JSON schema to track. Nothing breaks when either CLI renames a field.
It also means the machinery already exists:
tmux.Capture(ctx, paneID, lines, escapes) - the capture, already shared with the classifier.
agent.Classify - Waiting, Busy, Idle, Shell or Unknown, already trusted enough to drive push notifications.
tmux.SetPaneTitle - the write, already sanitized, already aimed at the right option.
New code is the model call, the prompt, and the fallback chain. Nothing else.
Rejected: reading the agents' transcript files
Recorded so it is not re-litigated. Both agents write JSONL transcripts - Codex at ~/.codex/sessions/…/rollout-*.jsonl, Claude at ~/.claude/projects/*/. Richer than a screen scrape, and it dies on one problem: nothing joins a transcript to a pane.
Three ways to join them, all bad:
Open file descriptors. Tested and dead. lsof -p 3362 shows no .jsonl; both agents append and close.
cwd plus process start time.pane_pid is the login shell, not the agent - %1 reports pane_current_command=codex, but its pane_pid 3362 is /bin/zsh started Aug 30, twelve days before the transcript. tmux-continuum also restores sessions in a batch, so many agents start in the same second, and resumed sessions attach to a transcript older than the process.
A SessionStart hook writing $TMUX_PANE and the session id. Exact, and it was the plan until the screen made it unnecessary. It brings a registry to keep clean, a trust re-prompt when hooks.json changes, no coverage for the twenty-one sessions already running, and one silent trap: payload.session_id is 01a08ba3-… while the filename carries payload.id, 01a08bd8-…. A glob for the session id finds nothing.
Transcripts also need subagent filtering - a subagent thread writes its own file in the same cwd and is the active writer while the parent waits, so "newest file for this cwd" reliably picks the helper's narrow job over the pane's real task (thread_source: subagent, or isSidechain: true). Reading the screen has no such problem: the pane shows the pane's own work.
The screen deletes all of this. No registry, no hooks, no id resolution, no sidechain filter, and it works on the twenty-one panes already running.
This is not an instruction
The obvious alternative is a line in AGENTS.md and CLAUDE.md: "name your pane before you start". Rejected. An instruction is a request, and a model drops it under load - exactly when nine agents are running and you need it most. A title that is usually there is worse than useless, because you stop trusting the empty ones.
One titler for every pane, including Claude's
Claude Code already writes its own generated title into the transcript as an ai-title record, matching what its pane shows:
89e5dfee AI rename pane tmux
c4ea6bad GitHub repo watchlist screen
6527749c Role revocation and access token expiration
5c012c55 Session guidance
Deliberately not used. It would make thirteen panes free and thirteen panes different: different length, different capitalisation, no state, and no control over quality - Session guidance and Separate worktree are exactly the titles that fail. Twenty-one panes are only scannable if every title was written to the same rule.
Two pane options, no parsing
@remux_title holds the task text alone. @remux_state holds the classifier's verdict. The status bar and the drawer compose them.
Not one packed string, because remux recomputes state every pass and the title only when the work changes. Baking state into the title means rewriting the title to change a prefix, and a delimiter for someone to parse later. Write @remux_state only when the value actually changes - #32 is the standing lesson about writes on a timer.
Why options rather than the real pane title: internal/tmux/actions.go:191 measured it. Codex and Claude Code rewrite pane_title on every render, so a name set with select-pane -Tsurvived one poll and was gone by the next. A user option is the only channel the running program cannot clobber.
@remux_state @remux_title
! venue filter waiting on you
✳ token refresh race working
✓ pr 364 review done
! is the half that earns its keep, and it needs no model. Across twenty-one panes the expensive question is not what each agent is doing, it is which three are blocked on an answer.
The model chain
Batch every changed pane into one call. Feed each pane the tail of its capture plus cwd and branch. Ask for 3-5 words, lowercase, no punctuation, and SAME when the task has not really changed, so titles do not flicker between synonyms.
Fall through on non-zero exit or empty output:
claude -p --model haiku
claude -p - whatever the account defaults to
codex exec - configured model, no -m, non-interactive and takes stdin
last user line from the capture, truncated
No model id is hardcoded beyond haiku. There is no codex models list and the only id in a thousand recent session records is gpt-6-astra, so pinning a "cheapest" one would be a guess that rots. Tier 4 cannot fail, so the chain always terminates.
Sanitize before it reaches tmux regardless of tier. SetPaneTitle already rejects control characters and the field separator.
Cost, and why it is near zero when I am away
Idle agents cost nothing on their own - a pane waiting for input is a process sitting on a read. The titler is the only thing here with its own schedule, so it is the only thing to gate.
Ungated this is not free: ten panes every two minutes is about 3,600 model calls a day. At roughly $0.0006 a call that is real money for pane titles.
ActivityGate. Skip any pane whose window activity has not moved. Already written, already the mechanism that took remux off 19% of a core. Most of the saving is here, and it is the reason nothing runs overnight.
HID idle.ioreg -c IOHIDSystem gives HIDIdleTime, nanoseconds since the last key or mouse event. Over ~15 minutes, skip the pass. Catches the case activity misses: an agent grinding unattended overnight. Fail open - if the parse breaks, assume I am at the desk.
Attached clients.tmux list-clients empty. Weakest, since detached is normal, but free.
Gated, the real number is closer to one call per new task: tens a day, a few cents.
Measured:claude -p --model haiku takes 4.6s wall for a trivial prompt, ~3s of it CLI startup rather than inference. Fine for a background pass, and it confirms batching is the right shape - the per-call floor is process startup, not tokens, so eight panes in one call cost about what one pane costs.
Tradeoff accepted: a long unattended run gets titled only after I come back, within one interval. I was not reading it anyway.
Not a separate application
It belongs in remux, driven by the existing poller.
remux already enumerates every pane on a loop.
ActivityGate already says which ones moved.
agent.Classify already computes the state.
SetPaneTitle already writes the name to the option agents cannot stomp.
The phone drawer is already the main consumer of the name.
Name it honestly: this is the first time remux writes to the real workspace on its own initiative rather than because a phone tapped something. It is a benign write - two pane options, verified not to move the cursor - but the invariant script has to cover it.
Acceptance
./scripts/laptop-invariant.sh before and after. No pane, window, session or client changed except @remux_title and @remux_state.
Two Codex panes started in the same repo get different, correct titles. This is the case every transcript-mapping heuristic failed.
A pane running a subagent keeps its own task title while the subagent works.
The twenty-one sessions already running get titled without restarting anything.
With no window activity anywhere, a full pass makes zero model calls.
Haiku unavailable: titles fall through to default Claude, then Codex, then truncated text. Never empty, never stale-but-confident.
A shell pane is never titled.
Follow-up outside this repo
The tmux status-right in dotfiles reads @remux_title and falls back to pane_title (9fea1ae). Once @remux_state exists it should render the state first.
Twenty-one of the twenty-five panes on this laptop are running an agent. Eight Codex, thirteen Claude Code, across three sessions. The thing that breaks down at that scale is not reading a pane, it is remembering which pane is which. You come back after lunch and you have to open four panes to find the one you cared about.
What the panes actually say right now
Read off the live workspace:
Two different failures. Codex titles itself with the folder name, so four panes doing four different things all read
shortlist. Claude Code writes a live task summary, sometimes useful (#339 venue filter), but it degrades to✳ Claude Codebefore the first task and to vague nouns likeSeparate worktreeafter it.The design
Read the screen, not the transcripts
A cheap model reads the tail of each agent pane's scrollback and writes a short title. That is the whole feature.
This is the same rule the rest of remux already runs on, and #35 states it outright: never parse agent output, just read the screen. It buys the same things here it bought there. One code path covers Codex, Claude Code, opencode, crush and whatever gets installed next year. No vendor JSON schema to track. Nothing breaks when either CLI renames a field.
It also means the machinery already exists:
tmux.Capture(ctx, paneID, lines, escapes)- the capture, already shared with the classifier.tmux.ActivityGate- which panes changed since last pass, fromwindow_activity, written for remux holds ~19% of a CPU core continuously: full-workspace capture sweeps run whether or not anyone is looking #32 precisely to stop pointless captures.agent.Classify- Waiting, Busy, Idle, Shell or Unknown, already trusted enough to drive push notifications.tmux.SetPaneTitle- the write, already sanitized, already aimed at the right option.New code is the model call, the prompt, and the fallback chain. Nothing else.
Rejected: reading the agents' transcript files
Recorded so it is not re-litigated. Both agents write JSONL transcripts - Codex at
~/.codex/sessions/…/rollout-*.jsonl, Claude at~/.claude/projects/*/. Richer than a screen scrape, and it dies on one problem: nothing joins a transcript to a pane.Three ways to join them, all bad:
lsof -p 3362shows no.jsonl; both agents append and close.pane_pidis the login shell, not the agent -%1reportspane_current_command=codex, but itspane_pid3362 is/bin/zshstarted Aug 30, twelve days before the transcript. tmux-continuum also restores sessions in a batch, so many agents start in the same second, and resumed sessions attach to a transcript older than the process.SessionStarthook writing$TMUX_PANEand the session id. Exact, and it was the plan until the screen made it unnecessary. It brings a registry to keep clean, a trust re-prompt whenhooks.jsonchanges, no coverage for the twenty-one sessions already running, and one silent trap:payload.session_idis01a08ba3-…while the filename carriespayload.id,01a08bd8-…. A glob for the session id finds nothing.Transcripts also need subagent filtering - a subagent thread writes its own file in the same cwd and is the active writer while the parent waits, so "newest file for this cwd" reliably picks the helper's narrow job over the pane's real task (
thread_source: subagent, orisSidechain: true). Reading the screen has no such problem: the pane shows the pane's own work.The screen deletes all of this. No registry, no hooks, no id resolution, no sidechain filter, and it works on the twenty-one panes already running.
This is not an instruction
The obvious alternative is a line in
AGENTS.mdandCLAUDE.md: "name your pane before you start". Rejected. An instruction is a request, and a model drops it under load - exactly when nine agents are running and you need it most. A title that is usually there is worse than useless, because you stop trusting the empty ones.One titler for every pane, including Claude's
Claude Code already writes its own generated title into the transcript as an
ai-titlerecord, matching what its pane shows:Deliberately not used. It would make thirteen panes free and thirteen panes different: different length, different capitalisation, no state, and no control over quality -
Session guidanceandSeparate worktreeare exactly the titles that fail. Twenty-one panes are only scannable if every title was written to the same rule.Two pane options, no parsing
@remux_titleholds the task text alone.@remux_stateholds the classifier's verdict. The status bar and the drawer compose them.Not one packed string, because remux recomputes state every pass and the title only when the work changes. Baking state into the title means rewriting the title to change a prefix, and a delimiter for someone to parse later. Write
@remux_stateonly when the value actually changes - #32 is the standing lesson about writes on a timer.Why options rather than the real pane title:
internal/tmux/actions.go:191measured it. Codex and Claude Code rewritepane_titleon every render, so a name set withselect-pane -Tsurvived one poll and was gone by the next. A user option is the only channel the running program cannot clobber.!is the half that earns its keep, and it needs no model. Across twenty-one panes the expensive question is not what each agent is doing, it is which three are blocked on an answer.The model chain
Batch every changed pane into one call. Feed each pane the tail of its capture plus cwd and branch. Ask for 3-5 words, lowercase, no punctuation, and
SAMEwhen the task has not really changed, so titles do not flicker between synonyms.Fall through on non-zero exit or empty output:
claude -p --model haikuclaude -p- whatever the account defaults tocodex exec- configured model, no-m, non-interactive and takes stdinNo model id is hardcoded beyond haiku. There is no
codex models listand the only id in a thousand recent session records isgpt-6-astra, so pinning a "cheapest" one would be a guess that rots. Tier 4 cannot fail, so the chain always terminates.Sanitize before it reaches tmux regardless of tier.
SetPaneTitlealready rejects control characters and the field separator.Cost, and why it is near zero when I am away
Idle agents cost nothing on their own - a pane waiting for input is a process sitting on a read. The titler is the only thing here with its own schedule, so it is the only thing to gate.
Ungated this is not free: ten panes every two minutes is about 3,600 model calls a day. At roughly $0.0006 a call that is real money for pane titles.
ActivityGate. Skip any pane whose window activity has not moved. Already written, already the mechanism that took remux off 19% of a core. Most of the saving is here, and it is the reason nothing runs overnight.ioreg -c IOHIDSystemgivesHIDIdleTime, nanoseconds since the last key or mouse event. Over ~15 minutes, skip the pass. Catches the case activity misses: an agent grinding unattended overnight. Fail open - if the parse breaks, assume I am at the desk.tmux list-clientsempty. Weakest, since detached is normal, but free.Gated, the real number is closer to one call per new task: tens a day, a few cents.
Measured:
claude -p --model haikutakes 4.6s wall for a trivial prompt, ~3s of it CLI startup rather than inference. Fine for a background pass, and it confirms batching is the right shape - the per-call floor is process startup, not tokens, so eight panes in one call cost about what one pane costs.Tradeoff accepted: a long unattended run gets titled only after I come back, within one interval. I was not reading it anyway.
Not a separate application
It belongs in remux, driven by the existing poller.
ActivityGatealready says which ones moved.agent.Classifyalready computes the state.SetPaneTitlealready writes the name to the option agents cannot stomp.Name it honestly: this is the first time remux writes to the real workspace on its own initiative rather than because a phone tapped something. It is a benign write - two pane options, verified not to move the cursor - but the invariant script has to cover it.
Acceptance
./scripts/laptop-invariant.shbefore and after. No pane, window, session or client changed except@remux_titleand@remux_state.Follow-up outside this repo
The tmux
status-rightin dotfiles reads@remux_titleand falls back topane_title(9fea1ae). Once@remux_stateexists it should render the state first.