Merge pull request #466 from irisitymichaelgrundberg/feat/pane-exit-reporting

feat(tmux): report that a pane's agent has exited (#446, part 1)
This commit is contained in:
Codeman maintainer
2026-09-23 11:32:02 +02:00
15 changed files with 1799 additions and 33 deletions
+23
View File
@@ -1371,6 +1371,19 @@ describe('session-routes', () => {
expect(body.success).toBe(false);
});
// Ark0N/Codeman#446: starting a command in the pane retracts `paneExit`, and
// the pane-exit watcher cannot write that retraction for us — its next tick
// finds the field already cleared, reports no change and persists nothing.
// Broadcasting without persisting leaves state.json saying the agent exited.
it('persists the session, not just broadcasts it', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(harness.ctx._session);
});
it('returns error if session is busy', async () => {
harness.ctx._session.isBusy.mockReturnValue(true);
const res = await harness.app.inject({
@@ -1447,6 +1460,16 @@ describe('session-routes', () => {
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
});
// Same reason as /interactive above (Ark0N/Codeman#446).
it('persists the session, not just broadcasts it', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/shell`,
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(harness.ctx._session);
});
it('returns error if session is busy', async () => {
harness.ctx._session.isBusy.mockReturnValue(true);
const res = await harness.app.inject({
+289
View File
@@ -0,0 +1,289 @@
/**
* @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 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
* runs for it, which is why `applyPaneExitBadge()` is a named function rather
* than a block inside the render loop.
*
* Port: N/A
*/
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', () => {
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const load = <T>(name: string) => {
const source = appJs.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n\\}`))?.[0];
if (!source) throw new Error(`${name} not found in app.js`);
return new Function(`${source}\nreturn ${name};`)() as T;
};
const paneExitLabel = load<(p: unknown) => string>('paneExitLabel');
it('renders nothing for an unknown answer, which must never read as alive', () => {
expect(paneExitLabel(undefined)).toBe('');
expect(paneExitLabel(null)).toBe('');
});
it('names the exit code', () => {
expect(paneExitLabel({ status: 137, at: 1 })).toBe('exited (137)');
});
it('shows a clean exit as 0 rather than hiding it', () => {
expect(paneExitLabel({ status: 0, at: 1 })).toBe('exited (0)');
});
it('names a signal death, which the maintainer wants kept on screen', () => {
expect(paneExitLabel({ signal: 9, at: 1 })).toBe('exited (signal 9)');
});
it('says only "exited" when tmux knew the pane died but not how', () => {
// Measured on tmux 3.2a: a SIGKILLed pane reports neither status nor signal.
// Showing that as "exited (0)" would make an unexplained death look clean.
expect(paneExitLabel({ at: 1 })).toBe('exited');
});
});
describe('the exited-agent badge in a tab', () => {
// The incremental render path is the only one a live session reaches: going
// from live to exited adds and removes no tab, so the full rebuild never runs
// for it. These drive that path's DOM work against a real tab element.
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const source = [
appJs.match(/function paneExitLabel\([\s\S]*?\n\}/)?.[0],
appJs.match(/function applyPaneExitBadge\([\s\S]*?\n\}/)?.[0],
].join('\n');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const applyPaneExitBadge = new Function('document', `${source}\nreturn applyPaneExitBadge;`)(dom.window.document) as (
tab: unknown,
paneExit: unknown
) => void;
const makeTab = () => {
const tab = dom.window.document.createElement('div');
tab.className = 'session-tab';
tab.innerHTML = '<span class="tab-name">w1-case</span>';
return tab;
};
const badge = (tab: { querySelector: (s: string) => { textContent: string | null } | null }) =>
tab.querySelector('.tab-exited-badge');
it('draws no badge while the answer is unknown', () => {
const tab = makeTab();
applyPaneExitBadge(tab, undefined);
expect(badge(tab)).toBeNull();
});
it('adds the badge after the name once the agent exits', () => {
const tab = makeTab();
applyPaneExitBadge(tab, { status: 137, at: 1 });
expect(badge(tab)?.textContent).toBe('exited (137)');
expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge');
});
it('marks the badge data-i18n-skip, like the other generated status text', () => {
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true);
});
it('updates the text in place rather than stacking a second badge', () => {
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
const first = badge(tab);
applyPaneExitBadge(tab, { status: 137, at: 2 });
expect(tab.querySelectorAll('.tab-exited-badge')).toHaveLength(1);
expect(badge(tab)).toBe(first);
expect(badge(tab)?.textContent).toBe('exited (137)');
});
it('marks the tab so the status dot can be quieted', () => {
// The dot renders from `status`, which stays `idle` or `busy` for an exited
// pane by design, so the tab carries the exit as a class and CSS does the
// rest. Without it a green or pulsing dot sits beside the badge.
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
expect(tab.classList.contains('tab-agent-exited')).toBe(true);
});
it('unmarks the tab when the pane comes back', () => {
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
applyPaneExitBadge(tab, undefined);
expect(tab.classList.contains('tab-agent-exited')).toBe(false);
});
it('removes the badge when the pane comes back', () => {
// The retraction half: a respawned pane must not keep reading "exited".
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
applyPaneExitBadge(tab, undefined);
expect(badge(tab)).toBeNull();
});
it('is what the incremental render path calls', () => {
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.
*
* ⚠ In styles.css the rules inside an at-rule are skipped, so the desktop
* cases describe a wide viewport with motion allowed. mobile.css is loaded
* separately for the phone cases, and there its @media blocks are FLATTENED
* rather than skipped, because that file is phone-and-tablet-only and its
* whole content sits inside them. jsdom reports a custom property
* unresolved, so the expected values are the `var(--x)` tokens the
* stylesheets write.
*/
const readRules = (file: string, flattenMedia: boolean): string[] => {
const out: string[] = [];
postcss.parse(readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8')).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 && !flattenMedia) 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));
out.push(`${selectors.join(',')} { ${decls.join(' ')} }`);
});
return out;
};
const dotRules = readRules('styles.css', false);
// index.html loads mobile.css after styles.css, so it goes last here too.
const phoneRules = [...dotRules, ...readRules('mobile.css', true)];
/** Paint the dot of one tab and read back what the cascade decided. */
const dot = (opts: { tab: string; dotState?: string; rail?: boolean; phone?: boolean }) => {
const railAttrs = opts.rail ? ` data-tab-orientation="vertical" data-tab-rail-detail="rich"` : '';
const container = opts.rail ? 'tab-rail' : 'session-tabs';
const rules = opts.phone ? phoneRules : dotRules;
const dom = new JSDOM(
`<!DOCTYPE html><html${railAttrs}><head><style>${rules.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('leaves an errored dot red, which is the state that offers a restart', () => {
// `status: 'error'` is the PTY-exit breaker's value and the browser answers
// it with a "restart it?" confirm, so it is a needs-you colour by the same
// argument that protects the two alert classes. Reachable when a restart of
// a dead pane keeps failing: the breaker trips while the pane stays dead.
expect(dot({ tab: 'tab-agent-exited', dotState: 'error' }).background).toBe('var(--red)');
});
it('mutes the dot on a phone, glow and all', () => {
// mobile.css enlarges the working dot to 9px and gives it a green glow with
// !important, and `status` stays `busy` for a pane whose agent died
// mid-turn — so without a phone-side rule this renders a grey dot wearing a
// green halo beside a badge reading "exited".
expect(dot({ tab: 'tab-agent-exited', dotState: 'busy', phone: true })).toMatchObject({
background: 'var(--text-muted)',
boxShadow: 'none',
});
});
it('keeps an alert red on a phone as well', () => {
expect(dot({ tab: 'tab-agent-exited tab-alert-action', dotState: 'busy', phone: true }).background).toBe(
'var(--red)'
);
});
it('finds the phone rules it is meant to be resolving', () => {
// Same self-guard as the desktop one: if mobile.css stopped contributing
// rules, every phone case above would pass against the desktop cascade.
expect(phoneRules.length).toBeGreaterThan(dotRules.length);
});
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)');
});
});
+350
View File
@@ -0,0 +1,350 @@
/**
* @fileoverview `SessionState.paneExit` — Codeman noticing that a pane's agent
* has exited (Ark0N/Codeman#446).
*
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
* CLI while tmux keeps the pane and the `tmux attach-session` process Codeman
* records as the session's pid. No PTY exit handler runs, and the record used to
* keep both its pid and `status: 'idle'`, so the board showed an exited session
* as a live idle one.
*
* Four properties are pinned here, each because getting it wrong costs something
* specific:
*
* 1. **The field is tri-state, and absence means UNKNOWN.** A direct-PTY
* session owns no pane, a remote SSH session's local pane holds the ssh
* client, and a docker case's local pane holds a `docker exec`. In all
* three, a dead local pane is not the agent exiting.
* 2. **`status` and `pid` are never touched.** `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.
* 3. **It reaches `toState()`**, which is both the `session:updated` payload
* and what `state.json` persists.
* 4. **It round-trips through the store**, because a reboot takes the tmux
* server and the persisted record is the only thing left that can say the
* agent was already gone.
*
* Port: 3187
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
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;
const EXIT: PaneExit = { status: 0, at: 1_700_000_000_000 };
/** The two mux members `Session` reads when it decides whether the field applies. */
const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer;
const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession;
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker;
/** A local, mux-backed session: the one shape the field applies to. */
function localMuxSession(extra: Record<string, unknown> = {}) {
return new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux: stubMux(),
muxSession: stubMuxSession(),
...extra,
});
}
describe('Session.setPaneExit scoping', () => {
it('accepts an exit for a local mux-backed session', () => {
const session = localMuxSession();
expect(session.setPaneExit(EXIT)).toBe(true);
expect(session.paneExit).toEqual(EXIT);
});
it('stays unknown for a direct-PTY session, which owns no pane at all', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: false });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('stays unknown while the session has no mux session yet', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: true, mux: stubMux() });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('stays unknown for a remote SSH session, whose pane holds the ssh client', () => {
// A dead ssh client means a transport drop OR an exit, and telling those two
// apart is the whole of PR #355. Publishing it as an agent exit would assert
// the answer Codeman does not have.
const session = localMuxSession({ remote });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('stays unknown for a docker case, whose pane holds a docker exec', () => {
const session = localMuxSession({ docker });
expect(session.setPaneExit(EXIT)).toBe(false);
expect(session.paneExit).toBeUndefined();
});
it('clears a stored exit when a later tick reports nothing', () => {
const session = localMuxSession();
session.setPaneExit(EXIT);
expect(session.setPaneExit(undefined)).toBe(true);
expect(session.paneExit).toBeUndefined();
});
it('reports no change when a tick repeats the same observation', () => {
// The caller persists and broadcasts on a true, and this tick runs every
// 2000 ms for every session.
const session = localMuxSession();
session.setPaneExit(EXIT);
expect(session.setPaneExit({ ...EXIT })).toBe(false);
});
it('reports a change when the exit status changes', () => {
const session = localMuxSession();
session.setPaneExit(EXIT);
expect(session.setPaneExit({ status: 137, at: EXIT.at })).toBe(true);
expect(session.paneExit).toEqual({ status: 137, at: EXIT.at });
});
});
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();
const before = session.toState();
session.setPaneExit(EXIT);
const after = session.toState();
expect(before.paneExit).toBeUndefined();
expect(after.paneExit).toEqual(EXIT);
expect(after.status).toBe(before.status);
expect(after.pid).toBe(before.pid);
// `status: 'error'` is the PTY-exit breaker's value; the browser answers it
// with a "restart it?" confirm.
expect(after.status).not.toBe('error');
});
it('restores a persisted exit so the first persist after boot cannot blank it', () => {
const session = localMuxSession({ paneExit: EXIT });
expect(session.toState().paneExit).toEqual(EXIT);
});
it('ignores a persisted exit for a session shape the field never applies to', () => {
// The scoping is not only about live ticks: a record written before a
// session was reconfigured must not resurrect an answer that cannot hold.
// The constructor alone has to enforce it, before any tick runs.
expect(new Session({ workingDir: '/tmp', mode: 'claude', useMux: false, paneExit: EXIT }).paneExit).toBeUndefined();
expect(localMuxSession({ remote, paneExit: EXIT }).paneExit).toBeUndefined();
expect(localMuxSession({ docker, paneExit: EXIT }).paneExit).toBeUndefined();
});
});
describe('a relaunch in the same pane forgetting the old exit', () => {
/** A mux whose respawn succeeds, so `restartCli()` reaches its success path. */
const respawningMux = () => {
const cleared: string[] = [];
const mux = {
isAvailable: () => true,
muxSessionExists: () => true,
respawnPane: async () => 4242,
clearPaneExit: (muxName: string) => cleared.push(muxName),
};
return { mux: mux as unknown as TerminalMultiplexer, cleared };
};
it('clears the exit when restartCli relaunches the CLI', async () => {
// restartCli() is the custom-model endpoint switch. Its caller persists and
// broadcasts straight afterwards, so an exit left in place here is written
// back onto a session that is running again.
const { mux, cleared } = respawningMux();
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux,
muxSession: stubMuxSession(),
paneExit: EXIT,
});
expect(session.paneExit).toEqual(EXIT);
expect(await session.restartCli()).toBe(true);
expect(session.paneExit).toBeUndefined();
expect(session.toState().paneExit).toBeUndefined();
// Both halves: the record and the mux layer's cache, the latter of which
// also invalidates a pane read already in flight.
expect(cleared).toEqual(['codeman-aaaa']);
});
it('leaves the exit alone when the relaunch fails', async () => {
// A failed respawn means the old command is still what the pane last ran.
const { mux, cleared } = respawningMux();
(mux as unknown as { respawnPane: () => Promise<null> }).respawnPane = async () => null;
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux,
muxSession: stubMuxSession(),
paneExit: EXIT,
});
expect(await session.restartCli()).toBe(false);
expect(session.paneExit).toEqual(EXIT);
expect(cleared).toEqual([]);
});
});
describe('paneExit round trip through state.json', () => {
let dir: string | null = null;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
dir = null;
});
it('survives a write and a reload, which is what a reboot restore reads', () => {
dir = mkdtempSync(join(tmpdir(), 'codeman-pane-exit-'));
const file = join(dir, 'state.json');
const stored: SessionState = {
...localMuxSession().toState(),
paneExit: { status: 0, signal: undefined, at: 1_700_000_000_000 },
};
const writer = new StateStore(file);
writer.setSession(stored.id, stored);
writer.saveNow();
const reader = new StateStore(file);
expect(reader.getSession(stored.id)?.paneExit).toEqual({ at: 1_700_000_000_000, status: 0 });
});
it('reads a record written before the field existed as unknown, needing no migration', () => {
dir = mkdtempSync(join(tmpdir(), 'codeman-pane-exit-'));
const file = join(dir, 'state.json');
const legacy = localMuxSession().toState();
delete legacy.paneExit;
const writer = new StateStore(file);
writer.setSession(legacy.id, legacy);
writer.saveNow();
expect(new StateStore(file).getSession(legacy.id)?.paneExit).toBeUndefined();
});
});
describe('a pane read reaching the session record', () => {
// Drives the real `paneExitsUpdated` wiring rather than asserting on source
// text: a fake reading goes into the mux, the event fires, and the session
// record is checked. This is the path findings about the watcher turn on.
let server: WebServer | null = null;
afterEach(async () => {
await server?.stop?.();
server = null;
});
const build = () => {
const web = new WebServer(PORT, false, true);
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
const session = localMuxSession();
(web as unknown as { sessions: Map<string, Session> }).sessions.set(session.id, session);
return { web, mux, session };
};
it('publishes an exit the mux reports for a local pane', () => {
const { web, mux, session } = build();
server = web;
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
expect(session.paneExit).toEqual(EXIT);
expect(session.toState().paneExit).toEqual(EXIT);
});
it('leaves status and pid alone while doing it', () => {
const { web, mux, session } = build();
server = web;
const before = session.toState();
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
const after = session.toState();
expect(after.status).toBe(before.status);
expect(after.pid).toBe(before.pid);
// `status: 'error'` is the PTY-exit breaker's value; it makes the browser
// offer a restart. A null pid makes it launch a fresh CLI.
expect(after.status).not.toBe('error');
});
it('retracts the exit once the mux reports the pane is back', () => {
const { web, mux, session } = build();
server = web;
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
mux.getPaneExit = () => undefined;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
expect(session.paneExit).toBeUndefined();
});
it('publishes nothing for a session the field does not apply to', () => {
const { web, mux } = build();
server = web;
const remoteSession = localMuxSession({ remote });
(web as unknown as { sessions: Map<string, Session> }).sessions.set(remoteSession.id, remoteSession);
mux.getPaneExit = () => EXIT;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
expect(remoteSession.paneExit).toBeUndefined();
});
});
+345 -17
View File
@@ -14,10 +14,14 @@ import {
buildRemoteKillCommand,
buildRemoteLaunchCommand,
formatPaneSnapshot,
parsePaneList,
parsePaneRows,
derivePaneExits,
hasObservablePaneSession,
type PaneRow,
resolveActivePaneTarget,
} from '../src/tmux-manager.js';
import { execSync, exec } from 'node:child_process';
import type { MuxSession } from '../src/mux-interface.js';
// ============================================================================
// Unit Tests (mocked)
@@ -910,41 +914,44 @@ describe('TmuxManager (unit)', () => {
// exec without TTY). See PR #71.
// ============================================================================
describe('parsePaneList', () => {
describe('parsePaneRows', () => {
/** Pull the name → pid map reconciliation builds, so these cases read as they used to. */
const pids = (output: string) => new Map(parsePaneRows(output).map((row) => [row.sessionName, row.pid]));
it('parses well-formed output into name → pid', () => {
const out = 'codeman-aaaa|1234\ncodeman-bbbb|5678\nclaudeman-cccc|9999';
const result = parsePaneList(out);
const result = pids(out);
expect(result.size).toBe(3);
expect(result.get('codeman-aaaa')).toBe(1234);
expect(result.get('codeman-bbbb')).toBe(5678);
expect(result.get('claudeman-cccc')).toBe(9999);
});
it('returns an empty map for empty output', () => {
expect(parsePaneList('').size).toBe(0);
it('returns no rows for empty output', () => {
expect(parsePaneRows('')).toEqual([]);
});
it('skips blank lines', () => {
const result = parsePaneList('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n');
const result = pids('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n');
expect(result.size).toBe(2);
expect(result.get('codeman-aaaa')).toBe(100);
expect(result.get('codeman-bbbb')).toBe(200);
});
it('skips lines without the separator', () => {
const result = parsePaneList('codeman-aaaa 1234\ncodeman-bbbb|5678');
const result = pids('codeman-aaaa 1234\ncodeman-bbbb|5678');
expect(result.size).toBe(1);
expect(result.get('codeman-bbbb')).toBe(5678);
});
it('skips lines with a non-numeric pid', () => {
const result = parsePaneList('codeman-aaaa|notapid\ncodeman-bbbb|5678');
const result = pids('codeman-aaaa|notapid\ncodeman-bbbb|5678');
expect(result.size).toBe(1);
expect(result.get('codeman-bbbb')).toBe(5678);
});
it('skips lines with an empty session name', () => {
const result = parsePaneList('|1234\ncodeman-bbbb|5678');
const result = pids('|1234\ncodeman-bbbb|5678');
expect(result.size).toBe(1);
expect(result.get('codeman-bbbb')).toBe(5678);
});
@@ -955,15 +962,336 @@ describe('parsePaneList', () => {
// tab byte. With the '|' separator, such literals must not be silently
// treated as a delimiter — the line is discarded because there is no '|'.
const literalBackslashT = 'codeman-aaaa\\t1234';
const result = parsePaneList(literalBackslashT);
expect(result.size).toBe(0);
expect(parsePaneRows(literalBackslashT)).toEqual([]);
});
it('splits on the first separator only', () => {
// Numeric trailing junk after the pid is tolerated by parseInt — proves
// that splitting on the first '|' leaves the pid extractable even if a
// future tmux ever appended extra fields.
const result = parsePaneList('codeman-aaaa|1234|extra-field');
expect(result.get('codeman-aaaa')).toBe(1234);
it('keeps a row whose pane_dead fields are missing, and calls its deadness unknown', () => {
// A tmux old enough to have shipped the previous two-field format, or one
// that dropped the trailing fields, must still yield its pid.
const [row] = parsePaneRows('codeman-aaaa|1234');
expect(row.pid).toBe(1234);
expect(row.dead).toBeUndefined();
expect(row.exitStatus).toBeUndefined();
expect(row.exitSignal).toBeUndefined();
});
it('reads a live pane as not dead, with no status or signal', () => {
// Measured against tmux 3.2a: a live pane leaves both numeric fields blank.
const [row] = parsePaneRows('codeman-aaaa|1234|0|||1');
expect(row.dead).toBe(false);
expect(row.exitStatus).toBeUndefined();
expect(row.exitSignal).toBeUndefined();
});
it('reads a dead pane with its exit status', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|1|7|');
expect(row.dead).toBe(true);
expect(row.exitStatus).toBe(7);
expect(row.exitSignal).toBeUndefined();
});
it('reads a dead pane with its killing signal', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|1||9');
expect(row.dead).toBe(true);
expect(row.exitStatus).toBeUndefined();
expect(row.exitSignal).toBe(9);
});
it('leaves a status of 0 as 0 rather than dropping it', () => {
// The whole point of the field: a clean exit is the case part 2 acts on.
const [row] = parsePaneRows('codeman-aaaa|1234|1|0|');
expect(row.exitStatus).toBe(0);
});
it('calls a non-numeric dead flag unknown rather than false', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|?||');
expect(row.dead).toBeUndefined();
});
it('returns one row per pane of a split session, in tmux order', () => {
const rows = parsePaneRows('codeman-aaaa|100|0|||\ncodeman-aaaa|200|1|0|');
expect(rows.map((row) => row.pid)).toEqual([100, 200]);
expect(rows.map((row) => row.sessionName)).toEqual(['codeman-aaaa', 'codeman-aaaa']);
});
});
describe('derivePaneExits', () => {
const NOW = 1_700_000_000_000;
it('reports a single dead pane with its exit status', () => {
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1|0|'), NOW);
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { status: 0, at: NOW } });
});
it('reports a signalled death without inventing a status', () => {
// Folding an absent status into 0 would turn an unexplained death into the
// clean exit part 2 closes on sight.
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1||9'), NOW);
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { signal: 9, at: NOW } });
});
it('reports a death tmux could not explain at all', () => {
// Measured on tmux 3.2a: a SIGKILLed pane reports pane_dead=1 and nothing else.
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1||'), NOW);
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { at: NOW } });
});
it('says nothing about a live pane', () => {
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|0|||'), NOW);
expect(exits.has('codeman-aaaa')).toBe(false);
});
it('says nothing about a pane whose deadness tmux did not report', () => {
expect(derivePaneExits(parsePaneRows('codeman-aaaa|1234'), NOW).size).toBe(0);
});
it('says nothing about a session with more than one pane, even when all are dead', () => {
// A session the user split by hand has no single "the agent" to report on,
// and guessing which pane speaks for it could call a live session exited.
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|\ncodeman-aaaa|200|1|0|'), NOW);
expect(exits.size).toBe(0);
});
it("answers per session, so one session's split does not silence another", () => {
const exits = derivePaneExits(
parsePaneRows('codeman-aaaa|100|1|0|\ncodeman-bbbb|200|1|0|\ncodeman-bbbb|201|0|||'),
NOW
);
expect([...exits.keys()]).toEqual(['codeman-aaaa']);
});
});
describe('TmuxManager pane-exit bookkeeping', () => {
const NOW = 1_700_000_000_000;
it('reports nothing before any tick has run', () => {
const manager = new TmuxManager();
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
});
it('keeps the timestamp of the FIRST tick that saw an unchanged exit', () => {
// The stamp says when the agent was found gone, so a pane that stays dead
// must not have its age reset every two seconds.
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 0, at: NOW });
});
it('starts a new observation when the exit status changes', () => {
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|137|'), NOW + 2000));
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 137, at: NOW + 2000 });
});
it('forgets the exit once the same session reports a live pane', () => {
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|0|||'), NOW + 2000));
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
});
it('prunes an exit for a session an authoritative read did not mention', () => {
// `list-panes -a` lists every pane on the socket, so a session missing from
// a successful read has no pane at all and no exit to report. Keeping the
// entry would grow the map forever as tmux sessions come and go outside
// killSession(). A FAILED or empty read never reaches here — refreshPaneExits
// returns before calling this, which is the case the next test covers.
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-bbbb|200|1|0|'), NOW));
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
expect(manager.getPaneExit('codeman-bbbb')).toEqual({ status: 0, at: NOW });
});
it('starts a new observation when the same status comes from a different pane pid', () => {
// A second command in the same pane that also exited 0 is a NEW death, and
// its `at` must say so. Only reachable when the respawn bypassed
// respawnPane() — a hand-run `tmux respawn-pane` — since every Codeman path
// clears the entry outright.
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|0|'), NOW + 60_000));
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 0, at: NOW + 60_000 });
});
it('forgets an exit on request, which is what a respawned pane needs', () => {
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.clearPaneExit('codeman-aaaa');
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
});
});
describe('the pane-exit watcher tick', () => {
// Every guard in `refreshPaneExits()` used to be unreachable: the method
// began with `if (IS_TEST_MODE) return;`, so deleting the generation check,
// the in-flight suppression, the empty-read rule or the read gate left the
// whole suite green. The tmux read now sits alone in `readPaneRows()`, which
// a subclass can answer for.
const NOW = 1_700_000_000_000;
class TestManager extends TmuxManager {
rows: PaneRow[] = [];
reads = 0;
/** While true, a read parks until releaseAll(), so a test can hold one in flight. */
hold = false;
private pending: (() => void)[] = [];
protected override async readPaneRows(): Promise<PaneRow[]> {
this.reads++;
// Every parked read is tracked, not just the latest: with the in-flight
// guard removed a second one starts, and a harness that could release
// only the last would deadlock instead of failing.
if (this.hold) await new Promise<void>((resolve) => this.pending.push(resolve));
return this.rows;
}
releaseAll(): void {
this.hold = false;
for (const resolve of this.pending.splice(0)) resolve();
}
}
const localSession = (sessionId = 's1'): MuxSession =>
({
sessionId,
muxName: `codeman-${sessionId}`,
pid: 100,
createdAt: 0,
workingDir: '/tmp',
mode: 'claude',
attached: true,
}) as MuxSession;
const withLocalSession = () => {
const manager = new TestManager();
manager.registerSession(localSession());
return manager;
};
it('does not read tmux when no session could answer', async () => {
const manager = new TestManager();
await manager.refreshPaneExits(NOW);
expect(manager.reads).toBe(0);
});
it('reads tmux once a local session exists', async () => {
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
await manager.refreshPaneExits(NOW);
expect(manager.reads).toBe(1);
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 0, at: NOW });
});
it('suppresses a second read while one is still in flight', async () => {
// EXEC_TIMEOUT_MS is 5000 against a 2000 ms tick, so a slow read outlives
// two ticks; without this the older one can resolve last and win.
const manager = withLocalSession();
manager.hold = true;
const first = manager.refreshPaneExits(NOW);
const second = manager.refreshPaneExits(NOW);
const reads = manager.reads;
manager.releaseAll();
await Promise.all([first, second]);
expect(reads).toBe(1);
});
it('retracts nothing when the read comes back empty', async () => {
// An empty read is "tmux did not answer". Retracting there would turn a
// transient failure into a silent denial of a death already observed.
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|137|');
await manager.refreshPaneExits(NOW);
manager.rows = [];
await manager.refreshPaneExits(NOW + 2000);
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW });
});
it('discards a read that started before the pane was cleared', async () => {
// The guard that stops an in-flight read from republishing a death over
// the pane that has just replaced it.
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
manager.hold = true;
const pending = manager.refreshPaneExits(NOW);
manager.clearPaneExit('codeman-s1');
manager.releaseAll();
await pending;
expect(manager.getPaneExit('codeman-s1')).toBeUndefined();
});
it('announces each tick so the server can publish it', async () => {
// Losing this emit, or the server's own startPaneExitWatcher() call,
// disables the whole feature with nothing failing.
vi.useFakeTimers();
try {
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1||9');
const updates: number[] = [];
manager.on('paneExitsUpdated', () => updates.push(1));
manager.startPaneExitWatcher(10);
await vi.advanceTimersByTimeAsync(25);
manager.stopPaneExitWatcher();
expect(updates.length).toBeGreaterThan(0);
expect(manager.getPaneExit('codeman-s1')).toMatchObject({ signal: 9 });
} finally {
vi.useRealTimers();
}
});
});
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.
});