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
+117 -16
View File
@@ -2,9 +2,10 @@
* @fileoverview The exited-agent badge on a session tab (Ark0N/Codeman#446).
*
* The server publishes `session.paneExit` when the agent inside a local tmux
* pane has exited while `remain-on-exit` kept the pane. These cover the two
* halves the browser owns: turning that field into a label, and getting the
* label onto and off a tab.
* pane has exited while `remain-on-exit` kept the pane. These cover the three
* things the browser owns: turning that field into a label, getting the label
* onto and off a tab, and what colour the tab's status dot ends up once the
* exit, the alert rules and the rich rail's own rules have all had a say.
*
* The incremental render path is the only one a live session ever reaches.
* Going from live to exited adds and removes no tab, so the full rebuild never
@@ -16,6 +17,7 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import postcss from 'postcss';
import { describe, expect, it } from 'vitest';
describe('the exited-agent tab label', () => {
@@ -120,19 +122,6 @@ describe('the exited-agent badge in a tab', () => {
expect(tab.classList.contains('tab-agent-exited')).toBe(false);
});
it('never quiets a dot that an alert has claimed', () => {
// A dot turning red or yellow because a session is blocked on a human
// outranks "the agent exited", so the CSS excludes both alert classes by
// hand rather than relying on the cascade.
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const rules = css.match(/\.session-tab\.tab-agent-exited[^{]*\{/g) ?? [];
expect(rules.length).toBeGreaterThan(0);
for (const rule of rules) {
expect(rule).toContain(':not(.tab-alert-action)');
expect(rule).toContain(':not(.tab-alert-idle)');
}
});
it('removes the badge when the pane comes back', () => {
// The retraction half: a respawned pane must not keep reading "exited".
const tab = makeTab();
@@ -145,3 +134,115 @@ describe('the exited-agent badge in a tab', () => {
expect(appJs).toContain('applyPaneExitBadge(tab, session.paneExit)');
});
});
describe('what colour the status dot ends up', () => {
/*
* The dot renders from `status`, which stays `idle` or `busy` for an exited
* pane, so the mute is a CSS rule keyed on the `tab-agent-exited` class. It
* competes with two other families of rule over the same dot, and this tree
* has lost that competition before: the alert rules and the rich-rail state
* rules already exclude each other by hand rather than by cascade.
*
* So the cascade is resolved rather than asserted from selector text. Every
* rule in styles.css that paints `.tab-status` goes into a real document and
* a real engine answers, which is what makes a rule moved up the file or a
* selector given one more class fail here.
*
* ⚠ Rules inside an at-rule are skipped, so this describes a desktop-width
* tab strip with motion allowed. jsdom reports a custom property unresolved,
* so the expected values are the `var(--x)` tokens the stylesheet writes.
*/
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const dotRules: string[] = [];
postcss.parse(css).walkRules((rule) => {
if (!rule.selector.includes('.tab-status')) return;
const parents: string[] = [];
let insideAtRule = false;
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
if (p.type === 'rule') parents.unshift(p.selector);
else insideAtRule = true;
}
if (insideAtRule) return;
const decls: string[] = [];
rule.each((node) => {
if (node.type === 'decl') decls.push(`${node.prop}: ${node.value}${node.important ? ' !important' : ''};`);
});
if (decls.length === 0) return;
const selectors = rule.selectors.map((sel) => (parents.length ? `${parents.join(' ')} ${sel}` : sel));
dotRules.push(`${selectors.join(',')} { ${decls.join(' ')} }`);
});
/** Paint the dot of one tab and read back what the cascade decided. */
const dot = (opts: { tab: string; dotState?: string; rail?: boolean }) => {
const railAttrs = opts.rail ? ` data-tab-orientation="vertical" data-tab-rail-detail="rich"` : '';
const container = opts.rail ? 'tab-rail' : 'session-tabs';
const dom = new JSDOM(
`<!DOCTYPE html><html${railAttrs}><head><style>${dotRules.join('\n')}</style></head><body>` +
`<div class="${container}"><div class="session-tab ${opts.tab}">` +
`<span id="dot" class="tab-status ${opts.dotState ?? 'idle'}"></span></div></div></body></html>`
);
const style = dom.window.getComputedStyle(dom.window.document.getElementById('dot')!);
return {
background: style.background,
opacity: style.opacity,
boxShadow: style.boxShadow,
animation: style.animation,
};
};
it('finds the rules it is meant to be resolving', () => {
// A selector rename that emptied this list would make every case below pass
// against a stylesheet with no rules in it.
expect(dotRules.some((rule) => rule.includes('tab-agent-exited'))).toBe(true);
expect(dotRules.some((rule) => rule.includes('tab-alert-action'))).toBe(true);
});
it('mutes the dot of an exited session', () => {
expect(dot({ tab: 'tab-agent-exited' })).toMatchObject({ background: 'var(--text-muted)', opacity: '0.5' });
});
it('leaves a live session green', () => {
expect(dot({ tab: '' }).background).toBe('var(--green)');
});
it('keeps a pending permission dialog RED on an exited session', () => {
// The one the maintainer asked for: the exit must not quiet an alert. A
// board that says two things at once is a board people stop trusting, and
// between "the agent is gone" and "this session is blocked on you", the
// one that needs a human wins.
expect(dot({ tab: 'tab-agent-exited tab-alert-action' }).background).toBe('var(--red)');
});
it('keeps a pending idle alert YELLOW on an exited session', () => {
expect(dot({ tab: 'tab-agent-exited tab-alert-idle' }).background).toBe('var(--yellow)');
});
it('mutes a dot the exit caught mid-turn, and stops it pulsing', () => {
// `.tab-status.busy` animates `pulse`, so muting the colour alone would
// leave a grey dot breathing as if the agent were still working.
expect(dot({ tab: 'tab-agent-exited', dotState: 'busy' })).toMatchObject({
background: 'var(--text-muted)',
opacity: '0.5',
animation: 'none',
});
});
it('mutes the dot on a rich tab rail too, halo included', () => {
// The rail's own state rules are far more specific than the strip's mute
// (measured: an exited session kept a full green dot AND the working halo),
// so the mute carries a rail twin that must stay below them in source order.
expect(dot({ tab: 'tab-agent-exited tab-state-working', dotState: 'busy', rail: true })).toMatchObject({
background: 'var(--text-muted)',
opacity: '0.5',
boxShadow: 'none',
});
expect(dot({ tab: 'tab-agent-exited tab-state-idle', rail: true }).background).toBe('var(--text-muted)');
});
it('still keeps an alert red on the rich tab rail', () => {
expect(
dot({ tab: 'tab-agent-exited tab-alert-action tab-state-working', dotState: 'busy', rail: true }).background
).toBe('var(--red)');
});
});
+38
View File
@@ -35,6 +35,7 @@ import { WebServer } from '../src/web/server.js';
import { StateStore } from '../src/state-store.js';
import type { PaneExit, SessionRemote, SessionDocker, SessionState } from '../src/types.js';
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
import { hasObservablePaneSession } from '../src/tmux-manager.js';
const PORT = 3187;
@@ -118,6 +119,43 @@ describe('Session.setPaneExit scoping', () => {
});
});
describe("the watcher's read gate agrees with the session's scoping", () => {
// `hasObservablePaneSession()` decides whether a watcher tick execs tmux at
// all, and `Session.paneExitApplies` decides whether the answer is kept. They
// are two copies of one rule, and drift between them is silent: too narrow
// and a session that could report an exit never gets read, too wide and every
// tick pays for an answer the session throws away.
const muxSession = (extra: Partial<MuxSession> = {}): MuxSession =>
({
sessionId: 'aaaa',
muxName: 'codeman-aaaa',
pid: 100,
createdAt: 0,
workingDir: '/tmp',
mode: 'claude',
attached: true,
...extra,
}) as MuxSession;
const cases: { shape: string; mux: MuxSession; session: () => Session }[] = [
{ shape: 'local', mux: muxSession(), session: () => localMuxSession() },
{ shape: 'remote SSH', mux: muxSession({ remote }), session: () => localMuxSession({ remote }) },
{ shape: 'docker', mux: muxSession({ docker }), session: () => localMuxSession({ docker }) },
{
shape: 'rebuilt from the socket',
mux: muxSession({ discovered: true }),
session: () => localMuxSession({ discoveredMuxSession: true }),
},
];
for (const { shape, mux, session } of cases) {
it(`agrees for a ${shape} session`, () => {
const sessionKeepsIt = session().setPaneExit(EXIT);
expect(hasObservablePaneSession([mux])).toBe(sessionKeepsIt);
});
}
});
describe('Session.toState with an exited agent', () => {
it('publishes the exit and leaves status and pid alone', () => {
const session = localMuxSession();
+55
View File
@@ -16,6 +16,7 @@ import {
formatPaneSnapshot,
parsePaneRows,
derivePaneExits,
hasObservablePaneSession,
resolveActivePaneTarget,
} from '../src/tmux-manager.js';
import { execSync, exec } from 'node:child_process';
@@ -1120,3 +1121,57 @@ describe('TmuxManager pane-exit bookkeeping', () => {
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
});
});
describe('hasObservablePaneSession', () => {
// The pane-exit watcher is always-on, so a tick with nothing to observe is
// the normal case on an instance running only remote or Docker work. This
// predicate is what keeps that tick from exec'ing tmux to find out.
const base = {
sessionId: 's1',
muxName: 'codeman-aaaa',
pid: 100,
createdAt: 0,
workingDir: '/tmp',
mode: 'claude' as const,
attached: true,
};
it('says no for an empty manager', () => {
expect(hasObservablePaneSession([])).toBe(false);
});
it('says yes for a local session, which is the whole reason the watcher runs', () => {
expect(hasObservablePaneSession([base])).toBe(true);
});
it('says no for a remote session, whose local pane holds the ssh client', () => {
expect(hasObservablePaneSession([{ ...base, remote: { host: 'box', user: 'me' } }])).toBe(false);
});
it('says no for a Docker case, whose local pane holds a `docker exec`', () => {
expect(hasObservablePaneSession([{ ...base, docker: { containerName: 'c1' } }])).toBe(false);
});
it('says no for a record rebuilt from the socket, which carries no provenance', () => {
// `reconcileSessions()` gives it a synthetic id that matches no state.json
// entry, so a remote session rediscovered that way looks local. Session
// forces UNKNOWN for it, so reading tmux for it buys nothing.
expect(hasObservablePaneSession([{ ...base, discovered: true }])).toBe(false);
});
it('says yes when one local session sits among sessions that cannot answer', () => {
// The read is one batched call for the whole socket, so a single local
// session is enough to make the tick worth paying for.
expect(
hasObservablePaneSession([
{ ...base, sessionId: 's1', remote: { host: 'box', user: 'me' } },
{ ...base, sessionId: 's2', discovered: true },
{ ...base, sessionId: 's3' },
])
).toBe(true);
});
// The predicate has to agree with `Session.paneExitApplies`, which is where
// the rule is enforced; that pairing is pinned in session-pane-exit.test.ts,
// where a real Session can answer for itself.
});