Skip to content

feat(tmux): report that a pane's agent has exited (#446, part 1) - #466

Merged
Ark0N merged 5 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:feat/pane-exit-reporting
Sep 23, 2026
Merged

Ark0N merged 5 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:feat/pane-exit-reporting

Conversation

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor

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-images removal — is not here and ships separately.

The bug

Codeman creates every tmux pane with remain-on-exit on. When the agent
exits, tmux keeps the pane and the tmux session, and the tmux attach-session
process 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 the
board 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 through
toState(), so it rides the existing session:updated broadcast and lands in
state.json through the same persist. No new SSE event.

One batched tmux list-panes -a per tick fills it, with #{pane_dead},
#{pane_dead_status} and #{pane_dead_signal} added to PANE_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 /wait route,
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:

  • The boot call sits inside 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/stop is what the browser sends when a user
    closes the Monitor panel (panels-ui.js). One browser closing a panel
    turns exit detection off for every other client of that process.
  • Nothing starts it when a session is created into a server that booted empty.

I measured it on an isolated instance before the change: a session created
after a clean boot had pane_dead=1 with status 0, and reported no paneExit
at all until I called /api/mux-sessions/stats/start by 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 -a per tick; only the
timer it hangs off changed. It reaches the server through an internal
paneExitsUpdated event, not an SSE one.

The tri-state, and what it is scoped to

undefined is the third state and it means UNKNOWN. It renders as nothing and
never 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.paneExitApplies is the single place the scoping lives, and it fails
closed for four shapes:

  • a direct-PTY session, which owns no pane at all;
  • a remote SSH session, whose local pane holds the ssh client — its death
    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;
  • a docker case, whose local pane holds a docker exec into the container's
    own tmux;
  • a session rebuilt from the socket. reconcileSessions() gives a discovered
    pane a synthetic restored-<fragment> id and generic metadata, and that id
    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 such a record, and absent metadata on
    it counts as unproven rather than as proof of locality.

Three more rules keep a positive answer trustworthy:

  • A session answers only when tmux listed exactly one pane for it. Codeman
    never splits a pane, so a session with two has been split by the user and
    has none that speaks for the agent.
  • A pane answers only when #{pane_dead} said 1 or 0. 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 pane_dead=1 with both #{pane_dead_status} and
    #{pane_dead_signal} empty, and #{pane_dead_signal} does not exist before
    tmux 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".

status and pid are untouched throughout. status: 'error' belongs to the
PTY-exit circuit breaker and makes the browser offer a restart. A null pid is
what 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, the
restartCli() 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_MS is 5000 ms
against 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 a
kill. 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 at rather than inheriting the
first death's timestamp.

A non-empty read of list-panes -a is authoritative for the whole socket, so
sessions 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 read
retracts 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/interactive refuses such a
session 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 and
reopening 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 SIGKILL and restarting the server left the
pane reading Error: Session ID 104c9e9f-… is already in use. The recovery
respawn relaunches with --session-id, and Claude refuses an id whose
transcript already exists. restartCli() already works around this by pinning
a resume id so the launch renders --resume <id> || --session-id <id>;
_setupOrAttachMuxSession() does not. This branch changes nothing about how a
respawn 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 stays idle or busy for an
exited 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-exited class that mutes the dot to the same treatment
.tab-status.ended already uses.

status itself is untouched, so this is a rendering rule and nothing that
reads 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 status alone.

Where it is persisted, and why

On the session record in state.json, not in mux-sessions.json. No
migration is needed.

mux-sessions.json cannot carry it. A reboot takes the tmux server, so
reconcileSessions() finds every tracked session dead, deletes it from
this.sessions and saves — the entries a reboot restore would want to consult
are exactly the ones a reboot erases.

state.json is also where the consumer will look. reboot-restore.ts reads
persisted SessionState records and never builds a Session, which is why it
cannot 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: StateStore reads state.json with JSON.parse and 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 the
tri-state and the correct reading of an older record. This follows CLAUDE.md's
"Session setting" recipe exactly — add to SessionState, include in
toState(), call persistSessionState().

at means when this server process first observed the pane dead. Nothing
records when the agent actually exited.

Tests

test/tmux-manager.test.ts — the parser and the derivation. Every existing
parsePaneList case carries over to parsePaneRows, including the
launchd/systemd literal-\t regression from #71. New: a live pane, a dead pane
with 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: at holds at the first read that saw the exit, a changed status starts
a 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 session
shapes, in the constructor and on a live reading; the field reaching
toState() with status and pid unchanged; restartCli() clearing on a
successful relaunch and not clearing on a failed one; the state.json round
trip; 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's paneExitsUpdated wiring.

npm run typecheck, npm run lint, npm run format:check,
npm run check:frontend-syntax and npm test all pass — 416 files and 7878
tests.

Manual verification

Run on an isolated beta instance (scripts/run-beta.sh: CODEMAN_INSTANCE=beta,
data dir ~/.codeman-beta, tmux socket codeman-beta, port 5000) on tmux 3.2a.
The production instance on the codeman socket was untouched throughout.

Before the timer change, a claude session with /exit typed into it:

  • tmux: codeman-4792b7b2|95775|1|0| — pane dead, status 0.
  • GET /api/sessions reported no paneExit until I started the stats
    collector by hand; then status=idle pid=95803 paneExit={'status': 0, 'at': 1789972528059}, with status and pid unchanged and the same value in
    ~/.codeman-beta/state.json. The at stamp was identical five ticks later.
  • The tab read "exit-probe EXITED (0)", with the terminal showing tmux's own
    "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:

  • a shell session, exit 137 typed into it;
  • tmux: codeman-3bba142c|1|137;
  • GET /api/sessions: paneExit={'status': 137, 'at': 1789976126742}, pid
    unchanged at 197167, status untouched;
  • the same value in ~/.codeman-beta/state.json;
  • the tab read "exited (137)".

A server restart with a pane still dead: recovery respawned it in place
(pane_dead=0, new pane pid) and paneExit was gone from both the live state
and 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:

  • /exit in a claude session produced EXITED (0), and the Monitor panel
    turned out to make no difference to it either way, which is the point of the
    separate watcher.
  • kill -9 on the agent produced a bare EXITED with no number. tmux reported
    that pane as dead=1 with 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, which
    is the diagnostic case answer 2 wants kept on screen.
  • Restarting the server with three dead panes respawned two of them and
    cleared their badges from the tab and from state.json. The third is the
    --session-id collision described above, and its badge correctly moved to
    exited (1) — a new observation with a new timestamp, not the old one
    lingering.

🤖 Generated with Claude Code

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>
… 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
Ark0N merged commit 6a01412 into Ark0N:master Sep 23, 2026
2 checks passed
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>
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
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:

  • The user manual's tab-appearance table (docs/wiki/The-Dashboard.md) now lists the exited state.
  • On the detailed sidebar and rail, an exited row showed a muted dot, an EXITED (137) badge and an idle pill underneath. The pill now reads exited (neutral style, timed from paneExit.at); sort order is unchanged.
  • The badge is aria-hidden like its siblings, and the tab's aria-label gains ", agent exited (...)" so screen readers hear it.
  • The junk-trailing-field parser case is back, against parsePaneRows.

One thing still open: the phone overview and the desktop home rail use the same classifier and still say idle for an exited session. Happy for that to ride along with part 2, or as its own small PR, whichever you prefer.

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.

2 participants