feat(tmux): report that a pane's agent has exited (#446, part 1) - #466
Merged
Ark0N merged 5 commits intoSep 23, 2026
Merged
Conversation
Codeman creates every tmux pane with `remain-on-exit on`. When the agent exits,
tmux keeps the pane, the tmux session, and the `tmux attach-session` process
Codeman records as the session's pid, so no PTY exit handler fires and nothing
writes the exit down. tmux itself knows: it marks the pane dead and reports the
exit status. This reads that.
`PANE_LIST_FORMAT` gains `#{pane_dead}`, `#{pane_dead_status}` and
`#{pane_dead_signal}`, and `startPaneExitWatcher()` refreshes a
muxName-to-observation map from ONE batched `tmux list-panes -a` per tick. Boot
reconciliation already ran that same call, so it now fills the map too and
recovery starts with a reading.
The watcher owns its own interval rather than riding `startStatsCollection()`,
which the issue suggested. That collector is armed when a browser opens the
Monitor panel and DISARMED when it closes it, and boot skips it entirely unless
recovery found a live session, so a session created on a freshly booted server
would publish nothing and one browser could turn detection off for every other.
Measured on an isolated instance: a dead pane with status 0 reported nothing
until `POST /api/mux-sessions/stats/start` was called by hand. It is still one
batched read per tick; only the timer changed.
Three rules keep a positive answer trustworthy. A session answers only when
tmux listed exactly one pane for it, because Codeman never splits a pane and a
session the user split by hand has none that speaks for the agent. A pane
answers only when `#{pane_dead}` said 1 or 0, because an empty field is a tmux
that did not answer. An absent status stays absent rather than becoming 0:
measured on tmux 3.2a, a SIGKILLed pane reports neither a status nor a signal,
and calling that a clean exit would be wrong in the direction that matters.
Two guards stop a slow read undoing a fast one. `EXEC_TIMEOUT_MS` is 5000 ms
against a 2000 ms interval, so a read can outlive two ticks: one already in
flight suppresses the next, and a generation counter that every
`clearPaneExit()` bumps discards a read that started before a respawn or a
kill. An observation also carries its pane pid, so a second command in the same
pane that exits the same way starts a new timestamp rather than inheriting the
first death's.
A non-empty read of `list-panes -a` is authoritative for the whole socket, so
sessions missing from it are pruned, which also bounds the map as tmux sessions
come and go outside `killSession()`. A failed or empty read retracts nothing.
The manager reports the raw pane reading and applies no session-shape scoping,
because the remote-reconnect watcher beside it needs exactly that raw reading.
`parsePaneList` becomes `parsePaneRows`, returning one row per pane instead of
a name-to-pid map; reconciliation builds its map from the rows. The parser's
existing cases carry over unchanged, including the launchd/systemd literal-tab
regression from PR Ark0N#71.
Refs Ark0N#446.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mux layer now knows a pane's agent has exited. This puts it on the session
record, where the board and, later, the reboot restore can see it.
`SessionState.paneExit` carries `{ status?, signal?, at }` and rides the
existing `session:updated` broadcast through `toState()`. No new SSE event. The
server pulls each answer from `mux.getPaneExit()` rather than off a broadcast
payload, so the raw reading never reaches a browser: for a remote or docker
session that reading is the death of an ssh client or a `docker exec`, not of
the agent.
The field is tri-state, and the third state is its absence: `undefined` means
Codeman does not know, and it never reads as alive. `Session.setPaneExit()`
forces that unknown for every shape a dead local pane does not describe. A
direct-PTY session owns no pane. A remote SSH session's local pane holds the
ssh client, whose death means a transport drop OR an exit, which is the
ambiguity PR Ark0N#355 settled by not guessing. A docker case's local pane holds a
`docker exec` into the container's own tmux. And a session rebuilt from the
socket has no provenance at all: `reconcileSessions()` gives it a synthetic
`restored-<fragment>` id that matches no `state.json` entry, so a remote
session rediscovered after `mux-sessions.json` was lost arrives with no
`remote` field and looks local — `MuxSession.discovered` marks it, and absent
metadata there counts as unproven rather than as proof. The scoping lives on
`Session` rather than in `TmuxManager` so there is one copy of the rule.
`status` and `pid` are untouched. `status: 'error'` belongs to the PTY-exit
circuit breaker and makes the browser offer a restart, and a null `pid` is what
makes the browser re-attach and launch a fresh CLI. A reading that repeats the
previous answer writes nothing and broadcasts nothing.
An unknown answer never reads as alive, but a stale KNOWN one would keep
reading as exited, so `clearPaneExitForNewPane()` retracts it on every path
that puts a new command in the pane: the start/attach path, the `restartCli()`
relaunch behind a custom-model switch, and the remote reattach. Without the
second of those, switching an endpoint on an exited session launched a new
command and then persisted and broadcast the old exit straight back onto it.
`toState()` is also what `state.json` persists, so the record survives a
reboot, which is the only thing that does: a reboot takes the tmux server, and
with it every live signal and every `mux-sessions.json` entry. Nothing reads it
there yet — making the restore refuse such a session is a behavior change that
belongs with the part that closes them. Recovery threads the saved value back
through the constructor so the first persist after boot cannot blank it.
Refs Ark0N#446.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tab now reads "exited (137)" beside the session name, drawn from the
`paneExit` field the server publishes. `applyPaneExitBadge()` owns the DOM
work, called from the incremental render path — the only path a live session
ever takes, since going from live to exited adds and removes no tab and so
never reaches the full rebuild.
An unknown answer draws nothing. A death tmux could not explain reads "exited"
with no number rather than "exited (0)", so an unexplained death and a clean
exit do not look alike. A signal death reads "exited (signal 9)".
The badge carries `data-i18n-skip`, like the status pills: it is generated
text, `i18n.js` walks inserted content, and a dictionary entry added later
would fight the renderer, whose in-place comparison is against English.
The tab also carries a `tab-agent-exited` class that mutes the status dot. That
dot is drawn from `status`, which stays `idle` or `busy` for an exited pane as
the issue requires, so without this a green or pulsing dot sits beside a badge
saying the agent is gone — the first thing a tester asked about. `status`
itself is untouched, so this is a rendering rule only. The CSS excludes the two
alert classes by hand, following the convention the rich-rail dot rules
document: a dot turning red or yellow because a session is blocked on a human
outranks "the agent exited".
The tab keeps its click behavior. X still closes it, and nothing here closes,
sweeps or restarts anything.
`docs/architecture-invariants.md` gains the mechanism under "Session data and
lifecycle", where every comparable one already lives: what the tri-state means,
the four shapes it is absent for, why the watcher cannot ride the stats
collector, why an absent `#{pane_dead_status}` is not 0, and the three things
that must never happen to an exited pane.
Refs Ark0N#446.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
irisitymichaelgrundberg
marked this pull request as ready for review
September 21, 2026 11:06
… too Four changes the maintainer asked for on Ark0N#446 before merging. The pane-exit watcher stays always-on, but a tick now costs nothing when there is nothing to observe. `hasObservablePaneSession()` skips the tmux exec while every session on the manager is one of the shapes `Session.paneExitApplies` already forces to UNKNOWN: a remote SSH session (its local pane holds the ssh client), a docker case (a `docker exec` into the container's own tmux), and a record rebuilt from the socket (no provenance at all). The timer is untouched. Skipping retracts nothing, for the same reason a failed read does not: the map still holds the last real reading, and every path that puts a new command in a pane calls `clearPaneExit()` itself. The two copies of that rule are pinned against each other in `test/session-pane-exit.test.ts`, because drift between them is silent in both directions. `DEFAULT_PANE_EXIT_INTERVAL_MS` was already a constant beside the stats and remote-reconnect intervals; its comment now says why the watcher owns its own cadence and why the number is what it is. The never-default-an-absent-status rule is written where `PaneExit` is declared. It names `status ?? 0` as the thing never to write, and says that an agent the OOM killer took would otherwise read as a user typing `/exit` — which is what absent-stays-absent keeps a later clean-exit sweep away from. Nothing fails when somebody adds that `??`, which is why the sentence is there rather than a test. Checking the dot's specificity found a second fight, and it was losing. On the tab strip the alert rules win as intended: a session that exits with a permission dialog pending still renders red, and yellow for an idle alert. On the rich vertical tab rail they did not — that rail's own `tab-state-*` dot rules are (0,9,1) against the strip's mute at (0,5,0), so an exited session there kept a full green dot AND the working halo beside a badge reading "exited". The rail twin matches that specificity exactly and therefore must stay below those rules in source order; it clears the halo as well, which the strip's rule never had to think about. `test/session-pane-exit-ui.test.ts` now resolves the real stylesheet in jsdom rather than matching selector text: postcss collects every rule that paints `.tab-status`, a real engine decides, and the tests read back the answer. Two mutations were run against it to prove it has teeth — dropping the hand-written alert exclusions fails three cases, and moving the rail twin above the state rules fails one. Refs Ark0N#446. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cher
Ten findings from a two-model review of this branch. Both reviewers cleared the
detection logic itself; everything here is a gap around it.
A route that starts a command in a pane now PERSISTS as well as broadcasts.
`/interactive` and `/shell` did neither before, and the pane-exit watcher cannot
cover for them: its next tick finds `paneExit` already cleared in memory,
reports no change and writes nothing, so `state.json` kept saying the agent had
exited for as long as the session stayed quiet. Nothing reads that record for a
decision yet, which is exactly why it had to be fixed now — part 2 is designed
to read it. The `clearPaneExitForNewPane()` docstring claimed its callers
already persisted; that claim was false for these two, and now says what the
caller owes instead.
The watcher's four guards were unreachable by any test. `refreshPaneExits()`
opened with `if (IS_TEST_MODE) return;`, so the read gate, the in-flight
suppression, the generation counter and the empty-read rule could each be
deleted with the whole suite green. The tmux call moves into `readPaneRows()`,
which a test subclass overrides — the shape `runRemoteReconnectTick` already
uses in this file for the same reason — and the test-mode gate moves with it, so
what a test cannot do is spawn a process rather than exercise the bookkeeping.
Each of the four guards now has a test that fails when it is deleted.
The muted status dot turned out to be a specificity fight on three surfaces, not
two. `.tab-status.error` was not excluded, so a session whose agent exited and
whose PTY-exit breaker then tripped lost its red dot to the mute — the state the
browser answers with a "restart it?" confirm, and a needs-you colour by the same
argument that protects the two alert classes. And mobile.css gives a `busy` dot
a 9px size and a green glow with `!important`, while `status` stays `busy` for a
pane whose agent died mid-turn, so a phone rendered a grey dot still wearing the
green halo beside a badge reading "exited". Both measured against the real
stylesheets, both now excluded, and the CSS test reads mobile.css too instead of
being structurally blind to half the problem.
Six comments said things that were not true. Two named the stats collector as
what replaces a restored reading, which is the opposite of the design. The
interval constant argued that 2000 ms keeps a read inside a tick, when the
5000 ms exec timeout means it cannot — which is why the in-flight guard exists.
`MuxSession.discovered` did not say the flag is permanent, though `saveSessions()`
serializes it. The empty-read docstring claimed a distinction that `|| true`
makes impossible. The invariants doc promised more than its drift test delivers.
And CLAUDE.md had no pointer at all, leaving its two hardest prohibitions
("never set `status: 'error'`", "never null the pid") only in the file it is
meant to route people to.
Refs Ark0N#446.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ark0N
pushed a commit
that referenced
this pull request
Sep 23, 2026
- docs/wiki/The-Dashboard.md: the tab-appearance table gains the exited state (muted dot plus an `exited (137)` badge) and explains the bare `exited` variant. - The detailed sidebar and rail no longer pair the muted dot with an "idle" pill: an exited session's pill reads "exited" (neutral styling) and its since stamp measures from the observed exit. This is a label override on the row model, not a new state, so SESSION_ACTIVITY_RANK and the home screen order are untouched, and a pending alert still keeps its own pill. The row signature includes the flag so the incremental path repaints it. - The exited badge is aria-hidden like its sibling badges, and the exit is appended to the tab's aria-label in both render paths through one helper. - test/tmux-manager.test.ts re-adds the junk-trailing-field parser case against parsePaneRows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
|
Merged, thanks @irisitymichaelgrundberg! This ships in 1.32.1. Everything I asked for on #446 is here and pinned: the named cadence constant, the read gate, the comment on the absent status, and a pending permission dialog still rendering red rather than muted. Writing down a fact tmux already knew, without acting on it yet, was the right size for part 1. At merge I added:
One thing still open: the phone overview and the desktop home rail use the same classifier and still say |
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Report that a pane's agent has exited (#446, part 1)
Part 1 of #446, following the maintainer's reply of 2026-09-20.
It writes down a fact the server can already see and has never recorded, so
that a user can see the state today and part 2 has something to act on.
Part 2 — closing an exited session, and guarding
cleanupSession()'s.claude-imagesremoval — is not here and ships separately.The bug
Codeman creates every tmux pane with
remain-on-exit on. When the agentexits, tmux keeps the pane and the tmux session, and the
tmux attach-sessionprocess that Codeman records as the session's pid keeps running. No PTY exit
handler fires, so the record keeps both its pid and
status: 'idle', and theboard shows an exited session as a live idle one.
tmux is not confused. It marks the pane dead and reports the exit status.
Codeman simply never reads it.
What this adds
SessionState.paneExit—{ status?, signal?, at }— published throughtoState(), so it rides the existingsession:updatedbroadcast and lands instate.jsonthrough the same persist. No new SSE event.One batched
tmux list-panes -aper tick fills it, with#{pane_dead},#{pane_dead_status}and#{pane_dead_signal}added toPANE_LIST_FORMAT.Boot reconciliation already ran that exact call, so it now fills the map too
and recovery starts with a reading instead of waiting for a tick.
The tab renders it as "exited (137)".
The three synchronous
isPaneDead()callers are untouched. The/waitroute,the TUI and the attach path each need an answer fresher than a 2000 ms tick,
and each keeps its own probe.
Why the read has its own timer rather than riding the stats tick
The spec said to read this on
startStatsCollection(). I built it that way,then found that collector is not running when you need it, so it now polls on
its own interval from
TmuxManager.startPaneExitWatcher().Three things turn the stats collector off:
if (alive.length > 0 || discovered.length > 0),so a server that boots with no live tmux session never starts it. That is
the first-run case and the post-host-reboot case.
POST /api/mux-sessions/stats/stopis what the browser sends when a usercloses the Monitor panel (
panels-ui.js). One browser closing a panelturns exit detection off for every other client of that process.
I measured it on an isolated instance before the change: a session created
after a clean boot had
pane_dead=1with status 0, and reported nopaneExitat all until I called
/api/mux-sessions/stats/startby hand.The new watcher starts unconditionally at boot, right beside
startMouseModeSync()and the remote-reconnect watcher, which already carry"Always start, even with no sessions — new sessions may be created later" for
the same reason. It is still one batched
list-panes -aper tick; only thetimer it hangs off changed. It reaches the server through an internal
paneExitsUpdatedevent, not an SSE one.The tri-state, and what it is scoped to
undefinedis the third state and it means UNKNOWN. It renders as nothing andnever as alive. It covers a running pane, a session the read did not list, a
failed probe, and every tick that has not run.
Session.paneExitAppliesis the single place the scoping lives, and it failsclosed for four shapes:
means a transport drop OR an exit, which is the ambiguity fix(remote): never auto-revive a remote session after a clean agent exit #355 settled by
not guessing;
docker execinto the container'sown tmux;
reconcileSessions()gives a discoveredpane a synthetic
restored-<fragment>id and generic metadata, and that idmatches no
state.jsonentry. So a remote session rediscovered aftermux-sessions.jsonwas lost arrives with noremotefield and lookslocal.
MuxSession.discoveredmarks such a record, and absent metadata onit counts as unproven rather than as proof of locality.
Three more rules keep a positive answer trustworthy:
never splits a pane, so a session with two has been split by the user and
has none that speaks for the agent.
#{pane_dead}said 1 or 0. An empty field is atmux that did not answer.
a SIGKILLed pane reports
pane_dead=1with both#{pane_dead_status}and#{pane_dead_signal}empty, and#{pane_dead_signal}does not exist beforetmux 3.4. Folding that into 0 would turn an unexplained death into the clean
exit part 2 closes on sight. The tab shows it as a bare "exited".
statusandpidare untouched throughout.status: 'error'belongs to thePTY-exit circuit breaker and makes the browser offer a restart. A null
pidiswhat makes the browser re-attach and launch a fresh CLI. Local panes keep
remain-on-exit on.Keeping a stale positive from surviving
An unknown answer never reads as alive, but a stale known answer would keep
reading as exited, so every path that puts a new command in the pane retracts
it.
Session.clearPaneExitForNewPane()covers the start/attach path, therestartCli()relaunch behind a custom-model switch, and the remote reattach,and it clears both the record and the mux layer's cache.
Two guards stop a slow read undoing a fast one.
EXEC_TIMEOUT_MSis 5000 msagainst a 2000 ms interval, so a read can outlive two ticks. A read already in
flight suppresses the next, and a generation counter that every
clearPaneExit()bumps discards a read that started before a respawn or akill. Without them, a read begun while the pane was dead could land after
respawnPane()cleared it and republish a live pane as exited.An observation also carries its pane pid, so a second command in the same pane
that happens to exit the same way starts a new
atrather than inheriting thefirst death's timestamp.
A non-empty read of
list-panes -ais authoritative for the whole socket, sosessions missing from it are pruned. That is also what stops the map growing
as tmux sessions come and go outside
killSession(). A failed or empty readretracts nothing.
Two notes from testing, neither of them part 1's doing
Clicking an exited tab does nothing. Answer 2 says clicking "keeps doing
what it does today, respawning in the same pane with the scrollback intact".
Measured on tmux 3.2a, clicking the tab of an exited session does nothing at
all: the browser re-attaches only when
session.pid === null(app.js:6300),and the premise of this issue is that an exited pane keeps its pid, so that
branch is never reached.
POST /api/sessions/:id/interactiverefuses such asession outright with "Session already has a running process". The respawn in
place happens at the next server restart, when recovery reaches
_setupOrAttachMuxSession()and finds the pane dead. Closing the tab andreopening the session from the Resume list does rebuild it with its scrollback,
which may be the path answer 2 meant. Part 1 neither depends on this nor
changes it. It is worth settling before part 2, because answer 3 argues that a
tab saying "exited" gives anyone who wants to look at one the time to do so,
and looking is currently all they can do.
Recovery can fail to respawn a pane, and this feature is what makes that
visible. Killing an agent with
SIGKILLand restarting the server left thepane reading
Error: Session ID 104c9e9f-… is already in use.The recoveryrespawn relaunches with
--session-id, and Claude refuses an id whosetranscript already exists.
restartCli()already works around this by pinninga resume id so the launch renders
--resume <id> || --session-id <id>;_setupOrAttachMuxSession()does not. This branch changes nothing about how arespawn is built or invoked, so the bug predates it — but before this change
the result was a dead pane reporting itself as a live idle session, and now the
tab says "exited (1)". Happy to send a separate PR for it. Note that part 2
would not sweep such a session either, since a failed relaunch is not a
positive status-0 reading.
One rendering choice beyond the spec
The status dot is drawn from
status, which staysidleorbusyfor anexited pane exactly as answer 2 requires. That leaves a green (or pulsing)
dot beside a badge saying the agent is gone, which reads as a contradiction
and was the first thing a tester asked about. The tab now carries a
tab-agent-exitedclass that mutes the dot to the same treatment.tab-status.endedalready uses.statusitself is untouched, so this is a rendering rule and nothing thatreads the field is affected. The CSS excludes the two alert classes by hand,
following the convention the rich-rail dot rules document: a dot turning red
or yellow because a session is blocked on a human outranks "the agent exited".
This is the one thing here I went beyond the spec on, so it is easy to drop if
you would rather the dot kept reporting
statusalone.Where it is persisted, and why
On the session record in
state.json, not inmux-sessions.json. Nomigration is needed.
mux-sessions.jsoncannot carry it. A reboot takes the tmux server, soreconcileSessions()finds every tracked session dead, deletes it fromthis.sessionsand saves — the entries a reboot restore would want to consultare exactly the ones a reboot erases.
state.jsonis also where the consumer will look.reboot-restore.tsreadspersisted
SessionStaterecords and never builds aSession, which is why itcannot see this case today; its own header says so. Nothing reads the field
there yet. Making the restore refuse such a session is a behavior change, and
it belongs with the part that closes exited sessions, so part 1 only records
it.
No migration:
StateStorereadsstate.jsonwithJSON.parseand a cast,with no schema to widen, and the field is optional. A record written before
this change simply has no
paneExit, which is already the UNKNOWN arm of thetri-state and the correct reading of an older record. This follows CLAUDE.md's
"Session setting" recipe exactly — add to
SessionState, include intoState(), callpersistSessionState().atmeans when this server process first observed the pane dead. Nothingrecords when the agent actually exited.
Tests
test/tmux-manager.test.ts— the parser and the derivation. Every existingparsePaneListcase carries over toparsePaneRows, including thelaunchd/systemd literal-
\tregression from #71. New: a live pane, a dead panewith a status, a signal death, a death tmux could not explain, a status of 0
surviving as 0, a non-numeric dead flag reading as unknown, a split session
staying silent, and one session's split not silencing another. Plus the merge
rules:
atholds at the first read that saw the exit, a changed status startsa new observation, the same status from a different pane pid starts a new one,
a live pane clears the entry, and an authoritative read prunes what it did not
list.
test/session-pane-exit.test.ts— the tri-state scoping for all five sessionshapes, in the constructor and on a live reading; the field reaching
toState()withstatusandpidunchanged;restartCli()clearing on asuccessful relaunch and not clearing on a failed one; the
state.jsonroundtrip; a pre-existing record reading as unknown; the tab label for each case;
the badge being added, updated in place and removed in a real DOM; and a
reading travelling through a real
WebServer'spaneExitsUpdatedwiring.npm run typecheck,npm run lint,npm run format:check,npm run check:frontend-syntaxandnpm testall pass — 416 files and 7878tests.
Manual verification
Run on an isolated beta instance (
scripts/run-beta.sh:CODEMAN_INSTANCE=beta,data dir
~/.codeman-beta, tmux socketcodeman-beta, port 5000) on tmux 3.2a.The production instance on the
codemansocket was untouched throughout.Before the timer change, a claude session with
/exittyped into it:codeman-4792b7b2|95775|1|0|— pane dead, status 0.GET /api/sessionsreported nopaneExituntil I started the statscollector by hand; then
status=idle pid=95803 paneExit={'status': 0, 'at': 1789972528059}, withstatusandpidunchanged and the same value in~/.codeman-beta/state.json. Theatstamp was identical five ticks later."Pane is dead (status 0, …)".
After the timer change, from a wiped data dir and a freshly booted server with
zero sessions, with the Monitor panel never opened:
exit 137typed into it;codeman-3bba142c|1|137;GET /api/sessions:paneExit={'status': 137, 'at': 1789976126742},pidunchanged at 197167,
statusuntouched;~/.codeman-beta/state.json;A server restart with a pane still dead: recovery respawned it in place
(
pane_dead=0, new pane pid) andpaneExitwas gone from both the live stateand the persisted record.
Live retraction without a reload: with the board open, respawning the pane
behind Codeman's back removed the badge on the next tick, which exercises the
incremental render path.
Torn down with
tmux -L codeman-beta kill-server.A second pass by a tester who had not seen the code, on the same instance:
/exitin a claude session producedEXITED (0), and the Monitor panelturned out to make no difference to it either way, which is the point of the
separate watcher.
kill -9on the agent produced a bareEXITEDwith no number. tmux reportedthat pane as
dead=1with an empty status AND an empty signal, because#{pane_dead_signal}does not exist before tmux 3.4. This is the case the"an absent status is not 0" rule exists for: folding it into 0 would have
shown
EXITED (0), and part 2 would then sweep away a crashed agent, whichis the diagnostic case answer 2 wants kept on screen.
cleared their badges from the tab and from
state.json. The third is the--session-idcollision described above, and its badge correctly moved toexited (1)— a new observation with a new timestamp, not the old onelingering.
🤖 Generated with Claude Code