fix(tmux): gate the pane-exit read, and mute the dot on the rich rail too

Four changes the maintainer asked for on Ark0N/Codeman#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/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-22 09:19:55 +02:00
co-authored by Claude Opus 5
parent c67c130caa
commit 90fd0a5a15
7 changed files with 289 additions and 19 deletions
+43 -1
View File
@@ -153,7 +153,13 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/** How often the pane-exit watcher re-reads every pane on the socket. */
/**
* How often the pane-exit watcher re-reads every pane on the socket. The
* watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
* lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
* Matched to the stats cadence above because both cost one batched tmux read,
* and kept well under EXEC_TIMEOUT_MS so a normal read finishes inside a tick.
*/
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
@@ -359,6 +365,33 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
return exits;
}
/**
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
* for unit testing.
*
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
* remote session's local pane holds the ssh client, a docker case's holds a
* `docker exec` into the container's own tmux, and a record rebuilt from the
* socket carries no provenance at all, so the session end forces all three to
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
* the answer.
*
* ⚠ This gates the READ, never the watcher. The watcher is always-on by
* design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
* with nothing to observe and picks the read straight back up as soon as one
* local session exists.
*/
export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolean {
for (const session of sessions) {
if (session.remote) continue;
if (session.docker) continue;
if (session.discovered === true) continue;
return true;
}
return false;
}
/**
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
* Prefers the active pane and falls back to the first valid pane.
@@ -3055,9 +3088,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Two guards keep a slow read from undoing a fast one. A read already in
* flight suppresses the next poll, and a read that started before a
* {@link clearPaneExit} is discarded when it lands.
*
* A third guard skips the read entirely while no session on this manager
* could produce an answer ({@link hasObservablePaneSession}). Skipping
* retracts nothing, for the same reason a failed read does not: the map
* still holds what the last real read saw, and every path that puts a new
* command in a pane calls {@link clearPaneExit} itself.
*/
async refreshPaneExits(now: number = Date.now()): Promise<void> {
if (IS_TEST_MODE) return;
// Nothing on this socket could answer, so do not exec tmux to find that
// out. See `hasObservablePaneSession`: the watcher above still ticks.
if (!hasObservablePaneSession(this.sessions.values())) return;
if (this.paneExitReadInFlight) return;
const generation = this.paneExitGeneration;
+15 -1
View File
@@ -645,9 +645,23 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
* reports `pane_dead=1` with BOTH `#{pane_dead_status}` and `#{pane_dead_signal}`
* empty, and `#{pane_dead_signal}` does not exist at all before tmux 3.4. So an
* absent `status` means "the exit code is unknown", never "the exit code is 0".
*
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
* the absent status IS how a signal death presents, so absent-stays-absent is
* the only thing keeping a future clean-exit sweep away from crashed agents:
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the
* types allow it, the label still renders, and the damage shows up only once
* the sweep lands. The rule is enforced in `derivePaneExits()`
* (`tmux-manager.ts`), which omits the key rather than defaulting it.
*/
export interface PaneExit {
/** tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux reported none. */
/**
* tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux
* reported none, which means UNKNOWN and never 0. See the ⚠ above before
* giving this a default anywhere.
*/
status?: number;
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
signal?: number;
+20
View File
@@ -18497,6 +18497,26 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
border-width: 2px;
}
/* The exited-agent mute, again, for the rich rail (Ark0N/Codeman#446).
The strip's rule is (0,5,0) and the three state rules above are (0,9,1), so
on this rail an exited session kept a full green dot and the working halo
beside a badge reading "exited" — measured, the contradiction the mute
exists to remove. This twin matches their (0,9,1) exactly and therefore MUST
stay below them in source order; moving it above silently restores the green
dot. It also clears the halo, which is a box-shadow the strip's rule never
had to think about.
⚠ The alert exclusions are repeated by hand for the same reason they are on
the rules above: a dot turning red or yellow because a session is blocked on
a human outranks "the agent exited". */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
.tab-rail
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle)
.tab-status {
background: var(--text-muted);
opacity: 0.5;
box-shadow: none;
}
/* Card accents: the same three colours as every other session surface, and the
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
on top of this for the sessions that are genuinely blocked on a human; these